feat: add comprehensive framework features and deployment improvements

Major additions:
- Storage abstraction layer with filesystem and in-memory implementations
- Gitea API integration with MCP tools for repository management
- Console dialog mode with interactive command execution
- WireGuard VPN DNS fix implementation and documentation
- HTTP client streaming response support
- Router generic result type
- Parameter type validator for framework core

Framework enhancements:
- Console command registry improvements
- Console dialog components
- Method signature analyzer updates
- Route mapper refinements
- MCP server and tool mapper updates
- Queue job chain and dependency commands
- Discovery tokenizer improvements

Infrastructure:
- Deployment architecture documentation
- Ansible playbook updates for WireGuard client regeneration
- Production environment configuration updates
- Docker Compose local configuration updates
- Remove obsolete docker-compose.yml (replaced by environment-specific configs)

Documentation:
- PERMISSIONS.md for access control guidelines
- WireGuard DNS fix implementation details
- Console dialog mode usage guide
- Deployment architecture overview

Testing:
- Multi-purpose attribute tests
- Gitea Actions integration tests (typed and untyped)
This commit is contained in:
2025-11-04 20:39:48 +01:00
parent 700fe8118b
commit 3ed2685e74
80 changed files with 9891 additions and 850 deletions

View File

@@ -14,6 +14,11 @@ cd framework
# Mit Docker starten # Mit Docker starten
make up make up
# Console Commands ausführen (empfohlen)
./bin/console # Docker-Wrapper für Console
./bin/console routes:list
./bin/console db:migrate
# Oder: Manuelle Installation # Oder: Manuelle Installation
composer install composer install
npm install npm install
@@ -22,6 +27,9 @@ npm install
# Für Backward Compatibility: cp .env.example .env (wird als Fallback geladen) # Für Backward Compatibility: cp .env.example .env (wird als Fallback geladen)
``` ```
> 📝 **Hinweis zu Permissions**: Verwende immer `./bin/console` für Console-Commands statt `php console.php`.
> Details: [docs/PERMISSIONS.md](docs/PERMISSIONS.md)
### Production Deployment ### Production Deployment
**Neu im Projekt? Starte hier:** **Neu im Projekt? Starte hier:**

12
bin/console Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Console Command Wrapper - Runs console.php in Docker container
# Usage: ./bin/console [command] [arguments]
cd "$(dirname "$0")/.." || exit 1
# Check if running in interactive terminal
if [ -t 0 ]; then
docker exec -it php php console.php "$@"
else
docker exec php php console.php "$@"
fi

View File

@@ -85,3 +85,9 @@ wireguard_config_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}.co
wireguard_private_key_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}_private.key" wireguard_private_key_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}_private.key"
wireguard_public_key_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}_public.key" wireguard_public_key_file: "{{ wireguard_config_path }}/{{ wireguard_interface }}_public.key"
wireguard_client_configs_path: "{{ wireguard_config_path }}/clients" wireguard_client_configs_path: "{{ wireguard_config_path }}/clients"
# WireGuard DNS Configuration
# DNS server for VPN clients (points to VPN server IP)
# This ensures internal services are resolved to VPN IPs
wireguard_dns_servers:
- "{{ wireguard_server_ip_default }}"

View File

@@ -81,7 +81,7 @@
- name: Extract server IP from config - name: Extract server IP from config
set_fact: set_fact:
server_vpn_ip: "{{ (wireguard_server_config_read.content | b64decode | regex_search('Address = ([0-9.]+)', '\\\\1')) | first | default('10.8.0.1') }}" server_vpn_ip: "{{ (wireguard_server_config_read.content | b64decode | regex_search('Address\\s*=\\s*([0-9.]+)')) | default(['10.8.0.1']) | first }}"
failed_when: false failed_when: false
- name: Extract WireGuard server IP octets - name: Extract WireGuard server IP octets

View File

@@ -41,7 +41,7 @@ services:
php: php:
container_name: php container_name: php
user: "${PHP_USER:-1000:1000}" user: "1000:1000" # Run as host user to prevent permission issues
volumes: volumes:
# Host-Mounts für direkten Zugriff (Development-friendly) # Host-Mounts für direkten Zugriff (Development-friendly)
- ./:/var/www/html:${VOLUME_MODE:-cached} - ./:/var/www/html:${VOLUME_MODE:-cached}
@@ -83,47 +83,8 @@ services:
- ./:/var/www/html:${VOLUME_MODE:-cached} - ./:/var/www/html:${VOLUME_MODE:-cached}
# NOTE: env_file not needed - Framework automatically loads .env.base → .env.local # NOTE: env_file not needed - Framework automatically loads .env.base → .env.local
db: # Database service removed - using external PostgreSQL Stack
container_name: db # Connection via app-internal network to external stack
ports:
- "${DB_EXTERNAL_PORT:-5433}:5432"
# Override environment to remove POSTGRES_PASSWORD (we use Docker Secrets via entrypoint)
environment:
POSTGRES_DB: ${DB_DATABASE:-michaelschiemer}
POSTGRES_USER: ${DB_USERNAME:-postgres}
# POSTGRES_PASSWORD is NOT set here - it's read from Docker Secret in entrypoint
# Performance & Connection Settings
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
PGDATA: /var/lib/postgresql/data/pgdata
secrets:
- db_user_password
# Use entrypoint to read password from Docker Secret
# This overrides the base.yml POSTGRES_PASSWORD environment variable
entrypoint: ["/bin/sh", "-c"]
command:
- |
POSTGRES_PASSWORD=$$(cat /run/secrets/db_user_password 2>/dev/null || echo '')
if [ -n "$$POSTGRES_PASSWORD" ]; then
export POSTGRES_PASSWORD
exec /usr/local/bin/docker-entrypoint.sh postgres -c config_file=/etc/postgresql/postgresql.conf
else
echo "⚠️ Warning: db_user_password secret not found, PostgreSQL may fail to start"
exec /usr/local/bin/docker-entrypoint.sh postgres -c config_file=/etc/postgresql/postgresql.conf
fi
restart: ${RESTART_POLICY:-unless-stopped}
logging:
driver: "${LOG_DRIVER:-local}"
options:
max-size: "${LOG_MAX_SIZE:-5m}"
max-file: "${LOG_MAX_FILE:-2}"
deploy:
resources:
limits:
memory: ${DB_MEMORY_LIMIT:-1G}
cpus: ${DB_CPU_LIMIT:-1.0}
reservations:
memory: ${DB_MEMORY_RESERVATION:-512M}
cpus: ${DB_CPU_RESERVATION:-0.5}
redis: redis:
container_name: redis container_name: redis

View File

@@ -1,367 +0,0 @@
# ⚠️ DEPRECATED - Legacy Docker Compose Configuration ⚠️
#
# This file is DEPRECATED and kept ONLY for backward compatibility during migration.
# ⚠️ DO NOT USE THIS FILE FOR NEW DEPLOYMENTS ⚠️
#
# This file will be REMOVED after the migration period (planned: Q2 2025).
# All developers must migrate to the Base+Override Pattern before then.
#
# ✅ PREFERRED: Use Base+Override Pattern:
# - docker-compose.base.yml (shared services)
# - docker-compose.local.yml (local development overrides)
# - docker-compose.staging.yml (staging overrides)
# - docker-compose.production.yml (production overrides)
#
# 📖 Usage:
# Local: docker compose -f docker-compose.base.yml -f docker-compose.local.yml up
# Staging: docker compose -f docker-compose.base.yml -f docker-compose.staging.yml up
# Production: docker compose -f docker-compose.base.yml -f docker-compose.production.yml up
#
# 🔗 See deployment/README.md for details on the Base+Override Pattern
# 🔗 See ENV_SETUP.md for environment configuration guide
#
# ⚠️ Migration Required:
# 1. Create .env.base from .env.example (run: make env-base)
# 2. Create .env.local for local overrides (run: make env-local)
# 3. Update all docker compose commands to use Base+Override files
# 4. Test your local setup before removing this legacy file
#
# 📅 Deprecation Timeline:
# - Created: Base+Override Pattern introduced
# - Planned Removal: Q2 2025 (after all developers have migrated)
# - Action Required: Migrate before removal date
services:
web:
container_name: web
build:
context: docker/nginx
dockerfile: Dockerfile
ports:
- "8888:80"
- "8443:443"
environment:
- APP_ENV=${APP_ENV:-development}
healthcheck:
test: ["CMD", "nc", "-z", "127.0.0.1", "443"]
interval: 30s
timeout: 10s
retries: 3
start_period: ${HEALTHCHECK_START_PERIOD:-10s}
logging:
driver: "${LOG_DRIVER:-local}"
options:
max-size: "${LOG_MAX_SIZE:-5m}"
max-file: "${LOG_MAX_FILE:-2}"
volumes:
- ./:/var/www/html:${VOLUME_MODE:-cached}
- ./ssl:/var/www/ssl:ro
depends_on:
php:
condition: service_started
restart: ${RESTART_POLICY:-unless-stopped}
networks:
- frontend
- backend
# Legacy .env file (Fallback for backward compatibility)
# Preferred: Use docker-compose.base.yml + docker-compose.local.yml
# See ENV_SETUP.md for new Base+Override Pattern
env_file:
- .env
deploy:
resources:
limits:
memory: ${WEB_MEMORY_LIMIT:-256M}
cpus: ${WEB_CPU_LIMIT:-0.5}
reservations:
memory: ${WEB_MEMORY_RESERVATION:-128M}
cpus: ${WEB_CPU_RESERVATION:-0.25}
php:
container_name: php
build:
context: .
dockerfile: docker/php/Dockerfile
args:
- ENV=${APP_ENV:-dev}
- COMPOSER_INSTALL_FLAGS=${COMPOSER_INSTALL_FLAGS:---no-scripts --no-autoloader}
user: "${PHP_USER:-1000:1000}"
logging:
driver: "${LOG_DRIVER:-local}"
options:
max-size: "${LOG_MAX_SIZE:-5m}"
max-file: "${LOG_MAX_FILE:-2}"
volumes:
# Shared Volume für Composer-Cache über Container-Neustarts hinweg
- composer-cache:/root/.composer/cache
# Bindet das Projektverzeichnis für Produktivbetrieb ein
#- project-data:/var/www/html:cached
# Variante mit mounting:
- ./:/var/www/html:${VOLUME_MODE:-cached}
# Verhindert Überschreiben der Vendor-Verzeichnisse
#- /var/www/html/vendor
# Host-Mounts für direkten Zugriff (Development-friendly)
- ./storage/logs:/var/www/html/storage/logs:rw
- ./storage/uploads:/var/www/html/storage/uploads:rw
- ./storage/analytics:/var/www/html/storage/analytics:rw
# Docker-Volumes für Performance (keine Host-Sync nötig)
- storage-cache:/var/www/html/storage/cache:rw
- storage-queue:/var/www/html/storage/queue:rw
- storage-discovery:/var/www/html/storage/discovery:rw
- var-data:/var/www/html/var:rw
environment:
PHP_IDE_CONFIG: "${PHP_IDE_CONFIG:-serverName=docker}"
APP_ENV: ${APP_ENV:-development}
APP_DEBUG: ${APP_DEBUG:-true}
XDEBUG_MODE: ${XDEBUG_MODE:-debug}
healthcheck:
test: [ "CMD", "php", "-v" ]
interval: 30s
timeout: 10s
retries: 3
restart: ${RESTART_POLICY:-unless-stopped}
networks:
- backend
- cache
# Legacy .env file (Fallback for backward compatibility)
# Preferred: Use docker-compose.base.yml + docker-compose.local.yml
env_file:
- .env
deploy:
resources:
limits:
memory: ${PHP_MEMORY_LIMIT:-512M}
cpus: ${PHP_CPU_LIMIT:-1.0}
reservations:
memory: ${PHP_MEMORY_RESERVATION:-256M}
cpus: ${PHP_CPU_RESERVATION:-0.5}
php-test:
container_name: php-test
build:
context: .
dockerfile: docker/php/Dockerfile.test
user: "1000:1000"
profiles:
- test
volumes:
- ./:/var/www/html:${VOLUME_MODE:-cached}
- composer-cache:/home/appuser/.composer/cache
- storage-cache:/var/www/html/storage/cache:rw
- storage-queue:/var/www/html/storage/queue:rw
- storage-discovery:/var/www/html/storage/discovery:rw
- var-data:/var/www/html/var:rw
environment:
APP_ENV: testing
APP_DEBUG: true
DB_HOST: db
REDIS_HOST: redis
networks:
- backend
- cache
# Legacy .env file (Fallback for backward compatibility)
env_file:
- .env
entrypoint: []
command: ["php", "-v"]
db:
container_name: db
image: postgres:16-alpine
restart: ${RESTART_POLICY:-unless-stopped}
environment:
POSTGRES_DB: ${DB_DATABASE:-michaelschiemer}
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-StartSimple2024!}
# Performance & Connection Settings
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- "${DB_EXTERNAL_PORT:-5433}:5432"
volumes:
- db_data:/var/lib/postgresql/data
- "${DB_CONFIG_PATH:-./docker/postgres/postgresql.conf}:/etc/postgresql/postgresql.conf:ro"
- "${DB_INIT_PATH:-./docker/postgres/init}:/docker-entrypoint-initdb.d:ro"
command:
- "postgres"
- "-c"
- "config_file=/etc/postgresql/postgresql.conf"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_DATABASE:-michaelschiemer}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
logging:
driver: "${LOG_DRIVER:-local}"
options:
max-size: "${LOG_MAX_SIZE:-5m}"
max-file: "${LOG_MAX_FILE:-2}"
networks:
- backend
deploy:
resources:
limits:
memory: ${DB_MEMORY_LIMIT:-1G}
cpus: ${DB_CPU_LIMIT:-1.0}
reservations:
memory: ${DB_MEMORY_RESERVATION:-512M}
cpus: ${DB_CPU_RESERVATION:-0.5}
redis:
container_name: redis
image: redis:7-alpine
volumes:
- "${REDIS_CONFIG_PATH:-./docker/redis/redis.conf}:/usr/local/etc/redis/redis.conf:ro"
- redis_data:/data
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
restart: ${RESTART_POLICY:-unless-stopped}
logging:
driver: "${LOG_DRIVER:-local}"
options:
max-size: "${LOG_MAX_SIZE:-5m}"
max-file: "${LOG_MAX_FILE:-2}"
networks:
- cache
# Legacy .env file (Fallback for backward compatibility)
env_file:
- .env
deploy:
resources:
limits:
memory: ${REDIS_MEMORY_LIMIT:-256M}
cpus: ${REDIS_CPU_LIMIT:-0.5}
reservations:
memory: ${REDIS_MEMORY_RESERVATION:-128M}
cpus: ${REDIS_CPU_RESERVATION:-0.25}
queue-worker:
container_name: queue-worker
build:
context: .
dockerfile: docker/worker/Dockerfile
user: "1000:1000" # Same user ID as PHP container
entrypoint: "" # Override any entrypoint
command: ["php", "/var/www/html/worker.php"] # Direct command execution
depends_on:
php:
condition: service_healthy
redis:
condition: service_healthy
db:
condition: service_healthy
volumes:
- ./:/var/www/html:cached
# Use same storage volumes as PHP container for consistency
- storage-cache:/var/www/html/storage/cache:rw
- storage-queue:/var/www/html/storage/queue:rw
- storage-discovery:/var/www/html/storage/discovery:rw
- ./storage/logs:/var/www/html/storage/logs:rw
- var-data:/var/www/html/var:rw
environment:
- APP_ENV=${APP_ENV:-development}
- WORKER_DEBUG=${WORKER_DEBUG:-false}
- WORKER_SLEEP_TIME=${WORKER_SLEEP_TIME:-100000}
- WORKER_MAX_JOBS=${WORKER_MAX_JOBS:-1000}
restart: unless-stopped
networks:
- backend
- cache
# Legacy .env file (Fallback for backward compatibility)
env_file:
- .env
# Graceful shutdown timeout
stop_grace_period: 30s
# Resource limits for the worker
deploy:
resources:
limits:
memory: 1G
reservations:
memory: 512M
minio:
container_name: minio
image: minio/minio:latest
restart: ${RESTART_POLICY:-unless-stopped}
environment:
- TZ=Europe/Berlin
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
command: server /data --console-address ":9001"
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
volumes:
- minio_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- backend
logging:
driver: "${LOG_DRIVER:-local}"
options:
max-size: "${LOG_MAX_SIZE:-5m}"
max-file: "${LOG_MAX_FILE:-2}"
deploy:
resources:
limits:
memory: ${MINIO_MEMORY_LIMIT:-512M}
cpus: ${MINIO_CPU_LIMIT:-0.5}
reservations:
memory: ${MINIO_MEMORY_RESERVATION:-256M}
cpus: ${MINIO_CPU_RESERVATION:-0.25}
# websocket:
# build:
# context: .
# container_name: websocket
# command: php websocket.php
# ports:
# - "8081:8081"
# networks:
# - frontend
# - backend
# volumes:
# - ./:/var/www/html
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: ${NETWORK_BACKEND_INTERNAL:-false}
cache:
driver: bridge
internal: ${NETWORK_CACHE_INTERNAL:-false}
volumes:
redis_data:
composer-cache:
# storage-data entfernt - wird jetzt granular gemountet
storage-cache: # Cache-Verzeichnis (Performance-kritisch)
storage-queue: # Queue-Verzeichnis (Performance-kritisch)
storage-discovery: # Discovery-Cache (Framework-intern)
var-data:
#cache-volume:
db_data:
project-data:
worker-logs:
worker-queue:
worker-storage: # Complete separate storage for worker with correct permissions
minio_data: # MinIO object storage data

226
docs/PERMISSIONS.md Normal file
View File

@@ -0,0 +1,226 @@
# Storage Permissions Management
## Problem
Docker containers laufen standardmäßig als `root` und erstellen Files/Directories mit root-Ownership.
Beim Zugriff vom Host aus (z.B. `console.php` direkt) gibt es dann Permission-Probleme.
## Implementierte Lösungen
### 1. Docker-Wrapper Script ⭐ (EMPFOHLEN)
**Location**: `./bin/console`
**Usage**:
```bash
# Direkt ausführen
./bin/console
./bin/console routes:list
./bin/console db:migrate
# Optional: In PATH aufnehmen
export PATH="$PATH:/home/michael/dev/michaelschiemer/bin"
console routes:list # Jetzt direkt aufrufbar
```
**Features**:
- ✅ Läuft automatisch im PHP-Container
- ✅ TTY-Detection (interaktiv/non-interaktiv)
- ✅ Keine Permission-Probleme
- ✅ Funktioniert in WSL, Linux, macOS
**Implementation**:
```bash
#!/usr/bin/env bash
# Auto-detect TTY und nutze entsprechende docker exec Flags
if [ -t 0 ]; then
docker exec -it php php console.php "$@"
else
docker exec php php console.php "$@"
fi
```
---
### 2. Container User Configuration ⭐
**Status**: ✅ Konfiguriert in `docker-compose.local.yml`
```yaml
php:
user: "1000:1000" # Läuft als Host-User
queue-worker:
user: "1000:1000" # Konsistent
php-test:
user: "1000:1000" # Konsistent
```
**Vorteile**:
- Container erstellt keine root-Files mehr
- Konsistente Permissions über alle Services
- Keine sudo-Rechte für Storage-Zugriffe nötig
**Einmalige Permissions-Korrektur**:
```bash
# Via Makefile (empfohlen)
make fix-perms
# Oder manuell
sudo chown -R $(id -u):$(id -g) storage/
```
---
### 3. Git Hooks (Automatisch) ⭐
**Location**:
- `.git/hooks/post-checkout`
- `.git/hooks/post-merge`
**Funktion**:
Automatische Permissions-Korrektur nach `git checkout` oder `git pull`
**Implementation**:
```bash
#!/usr/bin/env bash
# Prüfe ob storage-Directories root gehören
if find storage -user root 2>/dev/null | grep -q .; then
echo "🔧 Fixing storage permissions..."
sudo chown -R $(id -u):$(id -g) storage/
fi
```
**Wann ausgeführt**:
- Nach `git checkout <branch>`
- Nach `git pull`
- Nach `git merge`
---
## Troubleshooting
### Problem: "Permission denied for create directory"
**Symptom**:
```
Fehler: Permission denied for create directory on file: /home/michael/dev/michaelschiemer/storage/queue/priority
Parent directory: /home/michael/dev/michaelschiemer/storage/queue (owner: root, group: root, writable: no)
```
**Lösung 1** (Sofort):
```bash
make fix-perms
```
**Lösung 2** (Dauerhaft - Container verwenden):
```bash
./bin/console # Statt: php console.php
```
---
### Problem: Console-Command vom Host aus
**❌ Falsch**:
```bash
php console.php # Verwendet Host-PHP, hat keine Container-Permissions
console # Alias existiert nicht
```
**✅ Richtig**:
```bash
./bin/console # Docker-Wrapper Script
docker exec php php console.php # Direkt im Container
make console ARGS="command" # Via Makefile
```
---
### Problem: Git Hook fragt nach sudo-Password
**Erklärung**:
Hook versucht automatisch permissions zu fixen, benötigt sudo für chown.
**Optionen**:
1. **Password eingeben** - Hook korrigiert automatisch
2. **Abbrechen** - Später manuell `make fix-perms` ausführen
3. **Sudo-less chown** (advanced):
```bash
# In /etc/sudoers.d/storage-permissions
michael ALL=(ALL) NOPASSWD: /usr/bin/chown -R * /home/michael/dev/michaelschiemer/storage
```
---
## Best Practices
### Development Workflow
```bash
# 1. Container starten
make up
# 2. Console Commands im Container ausführen
./bin/console routes:list
./bin/console db:migrate
# 3. Bei Permission-Problemen
make fix-perms
# 4. Container neu starten (bei Config-Änderungen)
make restart
```
### Production Deployment
- **KEINE** Container-User-Config in Production
- Storage-Directories gehören `www-data` User
- Container laufen als `www-data` (uid/gid in Dockerfile gesetzt)
- Keine Host-Mounts (nur Docker Volumes)
---
## Maintenance
### Permissions Check
```bash
# Check storage permissions
ls -la storage/
# Find root-owned files
find storage -user root
# Fix all at once
make fix-perms
```
### Git Hooks Update
```bash
# Check if hooks are installed
ls -la .git/hooks/post-*
# Test hook manually
.git/hooks/post-checkout
# Remove hooks (disable auto-fix)
rm .git/hooks/post-checkout .git/hooks/post-merge
```
---
## Summary
**Empfohlener Workflow**:
1. ✅ Verwende `./bin/console` für Console-Commands
2. ✅ Container laufen als User 1000:1000 (development)
3. ✅ Git Hooks korrigieren automatisch nach pull/checkout
4. ✅ Bei Problemen: `make fix-perms`
**Vermeiden**:
- ❌ `php console.php` vom Host aus
- ❌ Console-Commands außerhalb des Containers
- ❌ Manuelle chown-Befehle (verwende `make fix-perms`)

View File

@@ -0,0 +1,777 @@
# Deployment Architecture
Comprehensive documentation of the deployment infrastructure for the Custom PHP Framework.
## Overview
The project uses a sophisticated multi-layered deployment approach:
- **Local Development**: Docker Compose with Base+Override pattern
- **Production**: Ansible-orchestrated deployment of separate Docker stacks
- **CI/CD**: Gitea Actions triggering Ansible playbooks
- **Infrastructure**: Modular service stacks (Traefik, PostgreSQL, Registry, Gitea, Monitoring, WireGuard)
**Deployment Status**: ~95% complete (Infrastructure and Application stacks complete, CI/CD pipeline configured but not tested)
---
## Docker Compose Architecture
### Base+Override Pattern
The project uses a modern Base+Override pattern instead of a monolithic docker-compose.yml:
```
docker-compose.base.yml # Shared service definitions
docker-compose.local.yml # Local development overrides
docker-compose.staging.yml # Staging environment overrides
docker-compose.production.yml # Production environment overrides
```
**Usage**:
```bash
# Local Development
docker compose -f docker-compose.base.yml -f docker-compose.local.yml up
# Production (via Ansible)
docker compose -f docker-compose.base.yml -f docker-compose.production.yml up
```
### Legacy docker-compose.yml
**⚠️ DEPRECATED**: The root `docker-compose.yml` is marked as DEPRECATED and kept only for backward compatibility during migration.
**Planned Removal**: Q2 2025
**DO NOT USE** for new deployments - use Base+Override pattern instead.
---
## Production Deployment Configuration
### docker-compose.production.yml
Production environment configuration with security hardening:
**Key Features**:
- **Docker Secrets**: Sensitive data managed via Docker Secrets pattern
- `DB_PASSWORD_FILE=/run/secrets/db_user_password`
- `REDIS_PASSWORD_FILE=/run/secrets/redis_password`
- `APP_KEY_FILE=/run/secrets/app_key`
- `VAULT_ENCRYPTION_KEY=/run/secrets/vault_encryption_key`
- **Security Hardening**:
- Container starts as root for gosu, drops to www-data
- `no-new-privileges:true` security option
- Minimal capabilities (ALL dropped, only CHOWN and DAC_OVERRIDE added)
- Environment: `APP_ENV=production`, `APP_DEBUG=false`
- **Services**:
- **web** (Nginx): Ports 80/443 exposed, SSL/TLS ready
- **php** (PHP-FPM): Application runtime with security constraints
- **redis**: Cache with Docker Secrets authentication
- **queue-worker**: Background job processing
- **scheduler**: Cron-like task scheduling
- **certbot**: Automatic SSL certificate management
**Example Service Configuration**:
```yaml
php:
restart: always
user: "root" # Container starts as root for gosu
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
environment:
- APP_ENV=production
- APP_DEBUG=false
- DB_PASSWORD_FILE=/run/secrets/db_user_password
secrets:
- db_user_password
- redis_password
- app_key
- vault_encryption_key
```
---
## Deployment Directory Structure
```
deployment/
├── ansible/ # Ansible automation
│ ├── playbooks/ # Deployment playbooks
│ │ ├── deploy-update.yml # Application deployment/update
│ │ ├── rollback.yml # Rollback to previous version
│ │ ├── setup-infrastructure.yml # Infrastructure provisioning
│ │ └── system-maintenance.yml # System maintenance tasks
│ ├── roles/ # Ansible roles
│ │ ├── common/ # Common server setup
│ │ ├── docker/ # Docker installation
│ │ ├── firewall/ # Firewall configuration
│ │ ├── monitoring/ # Monitoring setup
│ │ ├── postgresql/ # PostgreSQL stack
│ │ ├── registry/ # Docker Registry
│ │ ├── traefik/ # Traefik reverse proxy
│ │ └── wireguard/ # WireGuard VPN
│ ├── secrets/ # Ansible Vault encrypted secrets
│ ├── templates/ # Configuration templates
│ ├── group_vars/ # Group-specific variables
│ │ └── production.yml # Production environment config
│ ├── host_vars/ # Host-specific variables
│ ├── inventory/ # Server inventory
│ │ └── production # Production servers
│ └── ansible.cfg # Ansible configuration
├── stacks/ # Docker Stack definitions
│ ├── traefik/ # Reverse proxy & SSL
│ │ ├── docker-compose.yml
│ │ ├── traefik.yml # Traefik configuration
│ │ └── README.md
│ ├── postgresql/ # Database stack
│ │ ├── docker-compose.yml
│ │ └── README.md
│ ├── registry/ # Docker Registry
│ │ ├── docker-compose.yml
│ │ └── README.md
│ ├── gitea/ # Git server & CI/CD
│ │ ├── docker-compose.yml
│ │ └── README.md
│ ├── monitoring/ # Prometheus & Grafana
│ │ ├── docker-compose.yml
│ │ ├── prometheus.yml
│ │ └── README.md
│ ├── wireguard/ # VPN access
│ │ ├── docker-compose.yml
│ │ └── README.md
│ └── application/ # Main application
│ ├── docker-compose.yml
│ ├── README.md
│ └── configs/
├── docs/ # Documentation
│ ├── guides/ # How-to guides
│ │ ├── setup-guide.md # Complete setup walkthrough
│ │ └── ...
│ ├── status/ # Deployment status
│ │ ├── deployment-summary.md
│ │ └── ...
│ ├── tests/ # Test documentation
│ ├── history/ # Historical decisions
│ └── reference/ # Reference material
└── .gitea/ # CI/CD workflows
└── workflows/
└── deploy.yml # Gitea Actions deployment
```
---
## Ansible Automation
### Playbook Overview
**Infrastructure Setup** (`setup-infrastructure.yml`):
- Server provisioning and hardening
- Docker installation and configuration
- Firewall and security setup
- Stack deployment (Traefik, PostgreSQL, Registry, Gitea, Monitoring)
**Application Deployment** (`deploy-update.yml`):
- Pull latest code from Git
- Build Docker images
- Push to private registry
- Deploy application stack via docker-compose
- Run database migrations
- Restart services with zero-downtime
**Rollback** (`rollback.yml`):
- Rollback to previous Docker image tag
- Restore database from backup (if needed)
- Restart services
**System Maintenance** (`system-maintenance.yml`):
- System updates and security patches
- Docker cleanup (unused images, volumes)
- Log rotation
- Backup tasks
### Ansible Vault Secrets
**Encrypted Secrets** in `deployment/ansible/secrets/`:
- `production.vault.yml`: Production credentials (DB passwords, API keys, etc.)
- `registry.vault.yml`: Docker Registry authentication
- `gitea.vault.yml`: Gitea runner tokens and SSH keys
**Vault Operations**:
```bash
# Encrypt new file
ansible-vault encrypt deployment/ansible/secrets/production.vault.yml
# Edit encrypted file
ansible-vault edit deployment/ansible/secrets/production.vault.yml
# View encrypted file
ansible-vault view deployment/ansible/secrets/production.vault.yml
# Run playbook with vault
ansible-playbook -i inventory/production playbooks/deploy-update.yml --ask-vault-pass
```
### Inventory Configuration
**Production Inventory** (`inventory/production`):
```ini
[web_servers]
app.michaelschiemer.com ansible_host=YOUR_SERVER_IP ansible_user=deploy
[db_servers]
app.michaelschiemer.com
[all:vars]
ansible_python_interpreter=/usr/bin/python3
```
**Group Variables** (`group_vars/production.yml`):
- Domain configuration
- SSL/TLS settings
- Docker Registry URL
- Application-specific variables
- Resource limits (CPU, Memory)
---
## Docker Stacks
### Stack Architecture
Each service runs as an independent Docker stack for modularity and isolation:
#### 1. Traefik Stack
**Purpose**: Reverse proxy, SSL/TLS termination, Let's Encrypt integration
**Features**:
- Automatic SSL certificate management
- HTTP to HTTPS redirection
- Load balancing
- Service discovery via Docker labels
**Configuration**: `deployment/stacks/traefik/traefik.yml`
#### 2. PostgreSQL Stack
**Purpose**: Primary database for application
**Features**:
- Automated backups
- Connection pooling
- Replication support (optional)
- Monitoring integration
**Configuration**: `deployment/stacks/postgresql/docker-compose.yml`
#### 3. Docker Registry Stack
**Purpose**: Private Docker image registry
**Features**:
- Authentication via Basic Auth or LDAP
- SSL/TLS encryption
- Image scanning (optional)
- Integration with CI/CD pipeline
**Configuration**: `deployment/stacks/registry/docker-compose.yml`
#### 4. Gitea Stack
**Purpose**: Git server and CI/CD platform
**Features**:
- Git repository hosting
- Gitea Actions (GitHub Actions compatible)
- Gitea Runner for CI/CD execution
- Webhook support
**Configuration**: `deployment/stacks/gitea/docker-compose.yml`
#### 5. Monitoring Stack
**Purpose**: Metrics collection and visualization
**Features**:
- Prometheus for metrics collection
- Grafana for visualization
- Node Exporter for server metrics
- cAdvisor for container metrics
- Pre-configured dashboards
**Configuration**: `deployment/stacks/monitoring/docker-compose.yml`
#### 6. WireGuard Stack
**Purpose**: Secure VPN access to infrastructure
**Features**:
- Encrypted VPN tunnel
- Access to internal services
- Multi-device support
**Configuration**: `deployment/stacks/wireguard/docker-compose.yml`
#### 7. Application Stack
**Purpose**: Main PHP application
**Services**:
- **Nginx**: Web server
- **PHP-FPM**: PHP application runtime
- **Redis**: Cache and session storage
- **Queue Worker**: Background job processing
- **Scheduler**: Cron-like task scheduling
**Configuration**: `deployment/stacks/application/docker-compose.yml`
**Prerequisites**:
- Traefik stack running (for routing)
- PostgreSQL stack running (database)
- Docker Registry access (for image pulls)
- DNS configured (A records pointing to server)
---
## CI/CD Pipeline
### Gitea Actions Workflow
**Workflow File**: `.gitea/workflows/deploy.yml`
**Trigger Events**:
- Push to `main` branch (production deployment)
- Push to `staging` branch (staging deployment)
- Manual trigger via Gitea UI
**Workflow Steps**:
1. **Checkout Code**: Clone repository
2. **Build Docker Image**: Build application Docker image
3. **Push to Registry**: Push image to private Docker Registry
4. **Run Tests**: Execute test suite (PHPUnit/Pest)
5. **Deploy via Ansible**: Trigger Ansible playbook for deployment
6. **Health Check**: Verify deployment success
7. **Rollback on Failure**: Automatic rollback if health check fails
**Example Workflow**:
```yaml
name: Deploy to Production
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Build Docker image
run: |
docker build -t registry.michaelschiemer.com/app:${{ github.sha }} .
docker push registry.michaelschiemer.com/app:${{ github.sha }}
- name: Deploy via Ansible
run: |
ansible-playbook -i deployment/ansible/inventory/production \
deployment/ansible/playbooks/deploy-update.yml \
--extra-vars "image_tag=${{ github.sha }}" \
--vault-password-file=/secrets/vault-password
```
### Gitea Runner Setup
**Installation** (via Ansible):
- Gitea Runner installed on deployment server
- Configured with runner token from Gitea
- Systemd service for automatic start
- Docker-in-Docker (DinD) support for builds
**Configuration**:
```bash
# Register runner
gitea-runner register \
--instance https://gitea.michaelschiemer.com \
--token $RUNNER_TOKEN \
--name production-runner
# Start runner
systemctl start gitea-runner
systemctl enable gitea-runner
```
---
## Deployment Workflow
### Phase 1: Initial Setup (One-Time)
**1.1 Gitea Runner Registration**:
```bash
# On deployment server
ssh deploy@app.michaelschiemer.com
# Register Gitea Runner
gitea-runner register \
--instance https://gitea.michaelschiemer.com \
--token <RUNNER_TOKEN> \
--name production-runner
# Verify registration
gitea-runner list
```
**1.2 Ansible Vault Setup**:
```bash
# Create vault password file
echo "your-vault-password" > ~/.ansible/vault-password
chmod 600 ~/.ansible/vault-password
# Encrypt production secrets
cd deployment/ansible/secrets
ansible-vault create production.vault.yml
ansible-vault create registry.vault.yml
ansible-vault create gitea.vault.yml
```
**1.3 Server Provisioning**:
```bash
# Run infrastructure setup playbook
cd deployment/ansible
ansible-playbook -i inventory/production \
playbooks/setup-infrastructure.yml \
--vault-password-file ~/.ansible/vault-password
```
This provisions:
- Docker installation
- Firewall configuration
- All infrastructure stacks (Traefik, PostgreSQL, Registry, Gitea, Monitoring, WireGuard)
### Phase 2: Application Deployment (Repeatable)
**2.1 Manual Deployment** (via Ansible):
```bash
# Deploy/Update application
cd deployment/ansible
ansible-playbook -i inventory/production \
playbooks/deploy-update.yml \
--vault-password-file ~/.ansible/vault-password \
--extra-vars "image_tag=latest"
```
**2.2 Automated Deployment** (via CI/CD):
```bash
# Push to main branch triggers automatic deployment
git push origin main
# Or trigger manually via Gitea UI
# Repository → Actions → Deploy to Production → Run Workflow
```
### Phase 3: Rollback (If Needed)
```bash
# Rollback to previous version
cd deployment/ansible
ansible-playbook -i inventory/production \
playbooks/rollback.yml \
--vault-password-file ~/.ansible/vault-password \
--extra-vars "rollback_version=v1.2.3"
```
---
## Monitoring and Health Checks
### Application Health Endpoint
**URL**: `https://app.michaelschiemer.com/health`
**Response** (Healthy):
```json
{
"status": "healthy",
"checks": {
"database": "ok",
"redis": "ok",
"filesystem": "ok"
},
"timestamp": "2025-01-28T12:34:56Z"
}
```
### Prometheus Metrics
**URL**: `https://metrics.michaelschiemer.com` (via WireGuard VPN)
**Key Metrics**:
- Application response times
- Database query performance
- Redis cache hit rate
- Queue job processing rate
- System resources (CPU, Memory, Disk)
### Grafana Dashboards
**URL**: `https://grafana.michaelschiemer.com` (via WireGuard VPN)
**Pre-configured Dashboards**:
- Application Overview
- Database Performance
- Queue System Metrics
- Infrastructure Health
- Docker Container Stats
---
## Troubleshooting
### Deployment Failed
**Check Ansible Logs**:
```bash
# View last deployment log
tail -f /var/log/ansible/deploy-update.log
# Check playbook output
ansible-playbook -i inventory/production \
playbooks/deploy-update.yml \
-vvv # Verbose output
```
**Common Issues**:
- **Docker Registry Authentication Failed**: Check registry credentials in `secrets/registry.vault.yml`
- **Database Migration Failed**: Check database connectivity, review migration logs
- **Image Pull Failed**: Verify Docker Registry is accessible, image exists with specified tag
### Application Not Starting
**Check Docker Logs**:
```bash
# Application stack logs
ssh deploy@app.michaelschiemer.com
docker compose -f /opt/stacks/application/docker-compose.yml logs -f
# Specific service logs
docker compose logs -f php
docker compose logs -f nginx
```
**Common Issues**:
- **PHP Fatal Error**: Check PHP logs in `/var/log/app/php-error.log`
- **Database Connection Refused**: Verify PostgreSQL stack is running, check credentials
- **Redis Connection Failed**: Verify Redis stack is running, check authentication
### SSL Certificate Issues
**Check Traefik Logs**:
```bash
docker compose -f /opt/stacks/traefik/docker-compose.yml logs -f
```
**Verify Let's Encrypt Certificate**:
```bash
# Check certificate expiry
openssl s_client -connect app.michaelschiemer.com:443 -servername app.michaelschiemer.com \
| openssl x509 -noout -dates
# Force certificate renewal (if needed)
docker exec traefik /usr/bin/traefik \
--acme.email=admin@michaelschiemer.com \
--certificatesresolvers.letsencrypt.acme.email=admin@michaelschiemer.com
```
### Rollback Procedure
**Immediate Rollback**:
```bash
# 1. Identify previous working version
docker images | grep app
# 2. Execute rollback playbook
cd deployment/ansible
ansible-playbook -i inventory/production \
playbooks/rollback.yml \
--vault-password-file ~/.ansible/vault-password \
--extra-vars "rollback_version=<IMAGE_TAG>"
# 3. Verify health
curl -k https://app.michaelschiemer.com/health
```
---
## Best Practices
### Development Workflow
**1. Local Development**:
```bash
# Use local docker-compose override
docker compose -f docker-compose.base.yml -f docker-compose.local.yml up
```
**2. Test Before Push**:
```bash
# Run test suite
./vendor/bin/pest
# Code style check
composer cs
# Static analysis
composer analyze
```
**3. Feature Branch Deployment** (Optional):
```bash
# Create feature branch
git checkout -b feature/new-feature
# Deploy to staging (if configured)
git push origin feature/new-feature
# Triggers staging deployment
```
### Production Deployment
**1. Use Ansible for Deployments**:
- Never manually SSH and run docker commands
- Always use Ansible playbooks for consistency
- Ansible provides idempotency and rollback capability
**2. Monitor Deployments**:
- Watch Grafana dashboards during deployment
- Check application logs for errors
- Verify health endpoint returns 200 OK
**3. Database Migrations**:
- Always backup database before migrations
- Test migrations on staging first
- Migrations are automatically run by Ansible during deployment
**4. Zero-Downtime Deployments**:
- Ansible uses rolling updates for PHP-FPM workers
- Old containers remain until new containers are healthy
- Traefik automatically routes to healthy containers
### Security
**1. Secrets Management**:
- All secrets stored in Ansible Vault (encrypted)
- Production credentials never committed to Git
- Vault password stored securely (not in repository)
**2. Docker Secrets**:
- Sensitive environment variables use Docker Secrets (`_FILE` suffix)
- Secrets mounted at `/run/secrets/` (tmpfs, never written to disk)
**3. Network Isolation**:
- Services communicate via internal Docker networks
- Only Traefik exposes ports to public internet
- Database and Redis not publicly accessible
**4. SSL/TLS**:
- All traffic encrypted via Traefik
- Let's Encrypt automatic certificate renewal
- HTTP to HTTPS redirection enforced
---
## Future Improvements
### Planned Enhancements
**1. Blue-Green Deployments** (Q2 2025):
- Run two identical production environments
- Switch traffic between blue and green
- Instant rollback capability
**2. Database Replication** (Q3 2025):
- PostgreSQL primary-replica setup
- Read replicas for scaling
- Automatic failover
**3. Multi-Region Deployment** (Q4 2025):
- Deploy to multiple geographic regions
- DNS-based load balancing
- Regional failover
**4. Enhanced Monitoring** (Q1 2025):
- APM integration (Application Performance Monitoring)
- Distributed tracing
- Real-time alerting via PagerDuty/Slack
---
## Related Documentation
**Comprehensive Guides**:
- `deployment/docs/guides/setup-guide.md` - Complete 8-phase setup walkthrough
- `deployment/ansible/README.md` - Ansible automation details
- `deployment/stacks/application/README.md` - Application stack deep-dive
**Status & Progress**:
- `deployment/docs/status/deployment-summary.md` - Current deployment status (~95% complete)
**Framework Documentation**:
- `docs/claude/architecture.md` - Framework architecture overview
- `docs/claude/development-commands.md` - Development tools and commands
- `docs/claude/common-workflows.md` - Standard development workflows
---
## Quick Reference
### Common Commands
**Local Development**:
```bash
# Start local environment
docker compose -f docker-compose.base.yml -f docker-compose.local.yml up
# Run migrations
docker exec php php console.php db:migrate
# Run tests
./vendor/bin/pest
```
**Deployment (via Ansible)**:
```bash
# Deploy to production
cd deployment/ansible
ansible-playbook -i inventory/production playbooks/deploy-update.yml --ask-vault-pass
# Rollback
ansible-playbook -i inventory/production playbooks/rollback.yml --ask-vault-pass --extra-vars "rollback_version=v1.2.3"
# System maintenance
ansible-playbook -i inventory/production playbooks/system-maintenance.yml --ask-vault-pass
```
**Monitoring**:
```bash
# Check application health
curl -k https://app.michaelschiemer.com/health
# View logs
ssh deploy@app.michaelschiemer.com
docker compose -f /opt/stacks/application/docker-compose.yml logs -f
# Access Grafana (via WireGuard VPN)
https://grafana.michaelschiemer.com
```
---
**Last Updated**: 2025-01-28
**Deployment Status**: 95% Complete (CI/CD pipeline configured, pending testing)
**Next Critical Step**: Test CI/CD pipeline end-to-end

472
docs/console-dialog-mode.md Normal file
View File

@@ -0,0 +1,472 @@
# Console Dialog Mode
## Übersicht
Der Console Dialog Mode bietet eine einfache, textbasierte interaktive Schnittstelle für die Console, ähnlich einem AI-Assistenten. Im Gegensatz zur grafischen TUI (Text User Interface) verwendet der Dialog-Modus eine klassische Prompt-Eingabe mit Command-Vorschlägen und History-Support.
## Features
### ✅ Hauptfunktionen
- **Prompt-basierte Eingabe**: Einfache Text-Eingabe wie bei einem AI-Assistenten
- **Readline-Support**: Tab-Completion und History-Navigation (falls php-readline installiert)
- **Command-Vorschläge**: Automatische Vorschläge bei Tippfehlern
- **Kontextuelle Hilfe**: Hilfe während der Eingabe
- **Command-History**: Nachverfolgung und Anzeige von verwendeten Commands
- **Einfache Ausgabe**: Keine komplexe Terminal-Manipulation
### 🎯 Optional Features
- **Tab-Completion**: Automatische Vervollständigung von Commands
- **History-Navigation**: ↑/↓ Tasten für Command-History (mit Readline)
- **Command-Suggestions**: Intelligente Vorschläge bei Tippfehlern (Levenshtein-Similarity)
- **Kontextuelle Hilfe**: Detaillierte Hilfe während der Eingabe
- **Quoted Arguments**: Unterstützung für Argumente in Anführungszeichen
## Verwendung
### Dialog-Modus starten
```bash
# Dialog-Modus explizit starten
php console.php --dialog
# Alternative (Alias)
php console.php --chat
```
### Vergleich: TUI vs. Dialog-Modus
| Feature | TUI (Standard) | Dialog-Modus |
|---------|----------------|--------------|
| Start | `php console.php` | `php console.php --dialog` |
| Interface | Grafische Navigation | Prompt-Eingabe |
| Navigation | Maus + Tastatur | Nur Tastatur |
| Completion | Limited | Tab-Completion |
| History | ✅ | ✅ (mit Readline) |
| Suggestions | ✅ | ✅ (intelligent) |
| Terminal-Anforderungen | Kompatibles Terminal | Funktioniert überall |
### Standard-Verhalten
Ohne Argumente startet die Console standardmäßig die **TUI**:
```bash
php console.php # Startet TUI
php console.php --interactive # Startet TUI explizit
php console.php --tui # Startet TUI explizit
php console.php -i # Startet TUI explizit
```
## Built-in Commands
Der Dialog-Modus bietet mehrere Built-in Commands:
### `help` - Hilfe anzeigen
Zeigt alle verfügbaren Commands gruppiert nach Kategorien:
```bash
console> help
console> ?
console> :help
console> h
```
### `help <command>` - Detaillierte Hilfe
Zeigt detaillierte Hilfe für einen spezifischen Command:
```bash
console> help db:migrate
console> help user:create
```
### `history` - Command-History
Zeigt die letzten verwendeten Commands:
```bash
console> history
console> :history
```
### `clear` - Bildschirm löschen
Löscht den Bildschirm:
```bash
console> clear
console> :clear
```
### `exit` / `quit` - Beenden
Beendet den Dialog-Modus:
```bash
console> exit
console> quit
console> q
console> :q
```
## Command-Ausführung
### Basis-Commands
Commands werden direkt eingegeben:
```bash
console> db:migrate
console> user:list
console> cache:clear
```
### Commands mit Argumenten
Argumente werden nach dem Command angegeben:
```bash
console> user:create alice@example.com 25
console> db:migrate --force
```
### Quoted Arguments
Argumente mit Leerzeichen können in Anführungszeichen gesetzt werden:
```bash
console> user:create "John Doe" "john@example.com"
console> deploy "production environment"
```
## Readline-Support
### Installation
Für optimale Erfahrung sollte die `php-readline` Extension installiert sein:
```bash
# Ubuntu/Debian
sudo apt-get install php-readline
# macOS (Homebrew)
brew install php-readline
# Alpine
apk add php-readline
```
### Features mit Readline
Wenn Readline verfügbar ist, werden folgende Features aktiviert:
- **Tab-Completion**: Automatische Vervollständigung von Commands
- **History-Navigation**: ↑/↓ Tasten für Command-History
- **History-Persistenz**: History wird zwischen Sessions gespeichert
### Ohne Readline
Wenn Readline nicht verfügbar ist, funktioniert der Dialog-Modus weiterhin, aber ohne Tab-Completion und History-Navigation. Die History wird trotzdem gespeichert.
## Command-Vorschläge
### Automatische Vorschläge
Bei Tippfehlern werden automatisch ähnliche Commands vorgeschlagen:
```bash
console> db:migrat
Command 'db:migrat' not found.
Did you mean one of these?
• db:migrate (95% match - prefix match)
• db:migration:status (45% match - fuzzy match)
```
### Vorschlags-Algorithmus
Die Vorschläge basieren auf:
1. **Exact Match**: 100% Ähnlichkeit
2. **Prefix Match**: Command beginnt mit Eingabe (90% Ähnlichkeit)
3. **Contains Match**: Command enthält Eingabe (70% Ähnlichkeit)
4. **Levenshtein Distance**: Edit-Distanz-basierte Ähnlichkeit
5. **Word Similarity**: Wort-basierte Ähnlichkeit für Commands mit Doppelpunkt
### History-basierte Vorschläge
Commands aus der History haben Priorität:
- **Favorites**: Commands, die als Favoriten markiert sind
- **Recent**: Kürzlich verwendete Commands
- **Frequency**: Häufig verwendete Commands
## Kontextuelle Hilfe
### Während der Eingabe
Nach fehlgeschlagenen Commands wird kontextuelle Hilfe angezeigt:
```bash
console> db:migrate
Executing: db:migrate
────────────────────────────────────────────────────────────
✗ Command failed with exit code: 1
────────────────────────────────────────────────────────────
💡 Tip: Use "help db:migrate" for detailed help.
Description: Run database migrations
```
### Detaillierte Hilfe
Die detaillierte Hilfe zeigt:
- Command-Name und Beschreibung
- Usage-Beispiele
- Parameter-Dokumentation
- Optionen und Flags
- Beispiele
```bash
console> help db:migrate
📖 Command Help: db:migrate
════════════════════════════════════════════════════════════
Command: db:migrate
Description: Run database migrations
Usage:
php console.php db:migrate [options]
Options:
--force Force migration execution
--pretend Show SQL without executing
Examples:
php console.php db:migrate
php console.php db:migrate --force
php console.php db:migrate --pretend
```
## Command-History
### History-Verwaltung
Die History wird automatisch verwaltet:
- **Automatische Speicherung**: Jeder ausgeführte Command wird gespeichert
- **Persistenz**: History wird zwischen Sessions gespeichert
- **Deduplizierung**: Doppelte Commands werden aktualisiert, nicht dupliziert
- **Limit**: Standardmäßig werden 100 Commands gespeichert
### History anzeigen
```bash
console> history
📜 Command History
════════════════════════════════════════════════════════════
1. db:migrate (used 5 times, last: 2024-01-15 10:30:45)
2. user:create (used 3 times, last: 2024-01-15 09:15:22)
3. cache:clear (used 2 times, last: 2024-01-14 16:45:10)
```
### History-Navigation (mit Readline)
Mit Readline können Sie durch die History navigieren:
- **↑**: Vorheriger Command
- **↓**: Nächster Command
- **Tab**: Command-Vervollständigung
## Workflow-Beispiel
```bash
$ php console.php --dialog
🤖 Console Dialog Mode
════════════════════════════════════════════════════════════
Available commands: 42
✓ Readline support enabled (Tab completion, ↑/↓ history)
Type "help" for available commands or "exit" to quit.
console> help
📚 Available Commands
════════════════════════════════════════════════════════════
Database:
db:migrate
Run database migrations
db:seed
Seed database with test data
User:
user:create
Create a new user
user:list
List all users
console> user:create alice@example.com 25
Executing: user:create
Arguments: alice@example.com 25
────────────────────────────────────────────────────────────
✓ Command completed successfully
────────────────────────────────────────────────────────────
console> help user:create
📖 Command Help: user:create
════════════════════════════════════════════════════════════
Command: user:create
Description: Create a new user
Usage:
php console.php user:create <email> [age] [--admin]
Parameters:
email (string, required) - User email address
age (int, optional, default: 18) - User age
--admin (bool, optional) - Create as admin user
console> history
📜 Command History
════════════════════════════════════════════════════════════
1. user:create (used 1 times, last: 2024-01-15 10:30:45)
2. help (used 2 times, last: 2024-01-15 10:30:30)
console> exit
Goodbye! 👋
```
## Tipps & Best Practices
### Tab-Completion nutzen
Verwenden Sie Tab für Command-Vervollständigung:
```bash
console> db:mi<Tab> # Vervollständigt zu db:migrate
console> user:cr<Tab> # Vervollständigt zu user:create
```
### History effizient nutzen
- **↑/↓**: Navigieren Sie durch die History
- **History-Command**: Zeigen Sie alle Commands an
- **Readline**: Nutzen Sie Readline für bessere Erfahrung
### Hilfe verwenden
- **`help`**: Zeigt alle Commands
- **`help <command>`**: Detaillierte Hilfe für einen Command
- **Kontextuelle Hilfe**: Wird nach Fehlern automatisch angezeigt
### Commands mit Argumenten
- **Quoted Arguments**: Verwenden Sie Anführungszeichen für Argumente mit Leerzeichen
- **Flags**: Verwenden Sie `--flag` für boolesche Optionen
- **Positional Arguments**: Argumente werden in der Reihenfolge der Methoden-Parameter übergeben
## Troubleshooting
### Readline nicht verfügbar
Wenn Readline nicht verfügbar ist, sehen Sie:
```
⚠ Readline not available (install php-readline for better experience)
```
**Lösung**: Installieren Sie die `php-readline` Extension.
### Commands nicht gefunden
Wenn ein Command nicht gefunden wird, werden Vorschläge angezeigt:
```bash
console> db:migrat
Command 'db:migrat' not found.
Did you mean one of these?
• db:migrate (95% match)
```
### History funktioniert nicht
- **Ohne Readline**: History-Navigation (↑/↓) funktioniert nicht, aber History wird gespeichert
- **Mit Readline**: History-Navigation funktioniert automatisch
### Hilfe generiert Fehler
Wenn die Hilfe-Generierung fehlschlägt, wird eine Fallback-Ausgabe angezeigt:
```bash
console> help invalid:command
Command 'invalid:command' not found.
Did you mean one of these?
• valid:command (85% match)
```
## Architektur
### Komponenten
- **`ConsoleDialog`**: Haupt-Orchestrator für den Dialog-Modus
- **`DialogCommandExecutor`**: Command-Execution für Dialog-Modus
- **`CommandHistory`**: History-Verwaltung (geteilt mit TUI)
- **`CommandSuggestionEngine`**: Vorschlags-Engine (geteilt mit TUI)
- **`CommandHelpGenerator`**: Hilfe-Generator (geteilt mit TUI)
### Integration
Der Dialog-Modus ist vollständig in `ConsoleApplication` integriert:
```php
// In ConsoleApplication::run()
if (in_array($commandName, ['--dialog', '--chat'])) {
return $this->launchDialogMode();
}
```
### Wiederverwendung
Der Dialog-Modus nutzt die gleichen Komponenten wie die TUI:
- **CommandRegistry**: Command-Discovery und -Execution
- **CommandGroupRegistry**: Command-Kategorisierung
- **CommandHistory**: History-Verwaltung
- **CommandSuggestionEngine**: Vorschlags-Engine
## Unterschiede zur TUI
| Aspekt | TUI | Dialog-Modus |
|--------|-----|--------------|
| **Interface** | Grafisch mit Navigation | Prompt-basiert |
| **Terminal-Anforderungen** | Kompatibles Terminal erforderlich | Funktioniert überall |
| **Maus-Support** | ✅ | ❌ |
| **Tab-Completion** | Limited | ✅ (mit Readline) |
| **History-Navigation** | Limited | ✅ (mit Readline) |
| **Command-Suggestions** | ✅ | ✅ (intelligenter) |
| **Einfachheit** | Komplexer | Einfacher |
| **Best für** | Visuelle Navigation | Schnelle Eingabe |
## Zusammenfassung
Der Dialog-Modus bietet eine einfache, effiziente Alternative zur TUI für Benutzer, die:
- Eine klassische Prompt-Eingabe bevorzugen
- Schnelle Command-Ausführung ohne Navigation benötigen
- Tab-Completion und History-Navigation nutzen möchten
- In Terminals ohne TUI-Support arbeiten
**Start**: `php console.php --dialog` oder `php console.php --chat`

View File

@@ -0,0 +1,283 @@
# WireGuard DNS Fix - Implementation Status
**Status**: ✅ Phase 1 COMPLETED - DNS Configuration Added
**Datum**: 2025-01-29
**Implementiert**: DNS-Konfiguration in Ansible Variables
## Was wurde geändert?
### 1. Ansible Group Variables Update
**Datei**: `deployment/ansible/group_vars/production.yml`
**Änderung**:
```yaml
# WireGuard DNS Configuration
# DNS server for VPN clients (points to VPN server IP)
# This ensures internal services are resolved to VPN IPs
wireguard_dns_servers:
- "{{ wireguard_server_ip_default }}"
```
**Effekt**:
- Template `wireguard-client.conf.j2` wird jetzt `DNS = 10.8.0.1` in Client-Configs generieren
- Die `{% if wireguard_dns_servers | length > 0 %}` Bedingung im Template wird jetzt TRUE
- Alle neu generierten Client-Configs enthalten DNS-Konfiguration
## Wie funktioniert es?
### Template Logic (bereits vorhanden)
```jinja2
{% if wireguard_dns_servers | length > 0 %}
# DNS servers provided via Ansible (optional)
DNS = {{ wireguard_dns_servers | join(', ') }}
{% endif %}
```
### Generated Client Config (nach Regenerierung)
```ini
[Interface]
PrivateKey = <client_private_key>
Address = 10.8.0.7/24
DNS = 10.8.0.1 # ← JETZT ENTHALTEN!
[Peer]
PublicKey = <server_public_key>
Endpoint = michaelschiemer.de:51820
AllowedIPs = 10.8.0.0/24
PersistentKeepalive = 25
```
## Erwartetes Verhalten
### DNS Resolution (Windows Client)
```powershell
# Nach Import der neuen Config:
Get-DnsClientServerAddress | Where-Object {$_.InterfaceAlias -like "*WireGuard*"}
# Expected Output:
InterfaceAlias : WireGuard Tunnel wg0
ServerAddresses : {10.8.0.1} # ← VPN DNS Server
```
### Service Resolution
```powershell
Resolve-DnsName grafana.michaelschiemer.de
# Expected Output:
Name Type TTL Section IPAddress
---- ---- --- ------- ---------
grafana.michaelschiemer.de A 300 Answer 10.8.0.1 # ← VPN IP statt Public IP!
```
### HTTP Traffic Routing
```bash
# Traefik Access Log (Server-Side):
# VORHER (ohne DNS):
89.246.96.244 - - [Date] "GET /grafana HTTP/2.0" 404
↑ Public IP (FALSCH)
# NACHHER (mit DNS):
10.8.0.5 - - [Date] "GET /grafana HTTP/2.0" 200
↑ VPN IP (KORREKT)
```
## Nächste Schritte (PENDING)
### Phase 2: Client Config Regenerierung
**Für Windows Client "mikepc"**:
```bash
cd ~/dev/michaelschiemer/deployment/ansible
ansible-playbook -i inventory/production.yml \
playbooks/regenerate-wireguard-client.yml \
-e "client_name=mikepc" \
-e "client_ip=10.8.0.5"
```
**Output**:
- Backup: `mikepc.conf.backup-<timestamp>`
- Neue Config: `deployment/ansible/wireguard-clients/mikepc.conf`
- QR Code: `deployment/ansible/wireguard-clients/mikepc.png`
### Phase 3: Docker Container Test (OPTIONAL)
Teste VPN-Funktionalität in isolierter Umgebung:
```bash
ansible-playbook -i inventory/production.yml \
playbooks/test-wireguard-docker-container.yml \
-e "client_name=mikepc"
```
**Verifizierung**:
```bash
# Ping Test
docker exec wireguard-test-mikepc ping -c 4 10.8.0.1
# DNS Test
docker exec wireguard-test-mikepc nslookup grafana.michaelschiemer.de 10.8.0.1
# HTTP Test
docker exec wireguard-test-mikepc curl -v https://grafana.michaelschiemer.de
```
### Phase 4: Windows Client Import
1. **WireGuard Application öffnen**
2. **Tunnel "wg0" deaktivieren** (falls aktiv)
3. **Tunnel "wg0" löschen** (alte Config entfernen)
4. **Neue Config importieren**:
- "Add Tunnel" → "Import from file"
- Datei: `deployment/ansible/wireguard-clients/mikepc.conf`
5. **Tunnel "wg0" aktivieren**
### Phase 5: Verification (Windows)
**DNS Check**:
```powershell
Get-DnsClientServerAddress | Where-Object {$_.InterfaceAlias -like "*WireGuard*"}
# Expected: ServerAddresses = {10.8.0.1}
Resolve-DnsName grafana.michaelschiemer.de
# Expected: IPAddress = 10.8.0.1
```
**Browser Test**:
```
https://grafana.michaelschiemer.de
Expected: Grafana Dashboard OHNE 404 Error
```
**Server-Side Verification**:
```bash
# Traefik Access Log
ssh deploy@michaelschiemer.de
docker logs traefik --tail 50 | grep grafana
# Expected:
# 10.8.0.5 - - [Date] "GET /grafana HTTP/2.0" 200
# ↑ VPN IP statt Public IP!
```
## Troubleshooting
### Problem: DNS Still Not Working
**Check 1: Verify Config Contains DNS Line**
```powershell
Get-Content "C:\Path\To\mikepc.conf" | Select-String -Pattern "DNS"
# Expected:
DNS = 10.8.0.1
```
**Check 2: Verify Windows Uses VPN DNS**
```powershell
Get-DnsClientServerAddress | Format-Table InterfaceAlias, ServerAddresses
# WireGuard Interface should show 10.8.0.1
```
**Check 3: Flush DNS Cache**
```powershell
ipconfig /flushdns
Clear-DnsClientCache
```
### Problem: VPN Connects But Still Uses Public IP
**Check 1: Verify Routes**
```powershell
Get-NetRoute | Where-Object {$_.DestinationPrefix -eq "10.8.0.0/24"}
# Should exist with WireGuard interface
```
**Check 2: Test DNS Resolution**
```powershell
Resolve-DnsName grafana.michaelschiemer.de -Server 10.8.0.1
# Direct query to VPN DNS should work
```
### Problem: Cannot Reach grafana.michaelschiemer.de
**Check 1: CoreDNS on Server**
```bash
ssh deploy@michaelschiemer.de
docker ps | grep coredns
docker logs coredns
```
**Check 2: Traefik Configuration**
```bash
docker logs traefik | grep grafana
# Check for middleware configuration
```
## Rollback Plan
Falls Probleme auftreten:
### Rollback Client Config
```bash
# Restore backup on server
ssh deploy@michaelschiemer.de
cd /etc/wireguard/clients
cp mikepc.conf.backup-<timestamp> mikepc.conf
# Re-import on Windows
```
### Rollback Ansible Variables
```bash
git diff deployment/ansible/group_vars/production.yml
git checkout deployment/ansible/group_vars/production.yml
```
## Success Criteria
**DNS Configuration Added**: Ansible variables updated
**Client Config Regenerated**: PENDING
**Windows Client Import**: PENDING
**DNS Resolution Working**: PENDING
**HTTP/HTTPS via VPN**: PENDING
**Traefik Shows VPN IP**: PENDING
## Alternative Options (If DNS Fix Fails)
### Option B: Full Tunnel VPN
```yaml
# AllowedIPs = 0.0.0.0/0 statt 10.8.0.0/24
# Routes ALL traffic through VPN
```
### Option C: Alternative VPN Software
- OpenVPN (bewährt, stabil)
- Tailscale (managed, einfach)
- ZeroTier (mesh network)
## Referenzen
- **Implementation Plan**: `WIREGUARD-IMPLEMENTATION-PLAN.md`
- **Original Analysis**: `WIREGUARD-WINDOWS-ROUTING-FINAL-ANALYSIS.md`
- **DNS Solution**: `WIREGUARD-WINDOWS-DNS-FIX.md`
- **Template**: `deployment/ansible/templates/wireguard-client.conf.j2`
- **Variables**: `deployment/ansible/group_vars/production.yml`
## Notes
**Warum DNS-Konfiguration fehlt**:
- Template hatte bereits Unterstützung via `{% if wireguard_dns_servers | length > 0 %}`
- Variable `wireguard_dns_servers` fehlte in group_vars
- Jetzt gesetzt auf `["{{ wireguard_server_ip_default }}"]``["10.8.0.1"]`
**Erwarteter Effekt**:
- Alle neuen Client-Configs enthalten `DNS = 10.8.0.1`
- Windows nutzt VPN-DNS für Namensauflösung
- Interne Services (grafana.michaelschiemer.de) werden zu VPN-IP (10.8.0.1) aufgelöst
- HTTP/HTTPS Traffic geht über VPN statt Public Interface
**Nächster kritischer Schritt**:
Client Config für "mikepc" regenerieren und auf Windows importieren

File diff suppressed because it is too large Load Diff

View File

@@ -228,10 +228,24 @@ final readonly class CommandRegistry
private function normalizeCommandResult($result): ExitCode private function normalizeCommandResult($result): ExitCode
{ {
// Handle ActionResult (including ConsoleResult)
if ($result instanceof \App\Framework\Router\ActionResult) {
// If it's already a ConsoleResult, render it and return exit code
if ($result instanceof \App\Framework\Console\Result\ConsoleResult) {
// Rendering will be handled by ConsoleApplication::processCommandResult
return $result->exitCode;
}
// Convert other ActionResult types to ConsoleResult
return $this->convertActionResultToConsoleResult($result)->exitCode;
}
// Legacy ExitCode pattern
if ($result instanceof ExitCode) { if ($result instanceof ExitCode) {
return $result; return $result;
} }
// Legacy int pattern (for backwards compatibility)
if (is_int($result)) { if (is_int($result)) {
try { try {
return ExitCode::from($result); return ExitCode::from($result);
@@ -240,6 +254,7 @@ final readonly class CommandRegistry
} }
} }
// Legacy bool pattern
if (is_bool($result)) { if (is_bool($result)) {
return $result ? ExitCode::SUCCESS : ExitCode::GENERAL_ERROR; return $result ? ExitCode::SUCCESS : ExitCode::GENERAL_ERROR;
} }
@@ -247,6 +262,42 @@ final readonly class CommandRegistry
return ExitCode::SUCCESS; return ExitCode::SUCCESS;
} }
/**
* Convert ActionResult to ConsoleResult
*/
private function convertActionResultToConsoleResult(\App\Framework\Router\ActionResult $result): \App\Framework\Console\Result\ConsoleResult
{
// GenericResult: Use built-in conversion
if ($result instanceof \App\Framework\Router\GenericResult) {
return $result->toConsoleResult();
}
// Convert JsonResult to TextResult
if ($result instanceof \App\Framework\Router\Result\JsonResult) {
$json = json_encode($result->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
return \App\Framework\Console\Result\TextResult::info($json);
}
// Convert ToolResult to TextResult
if ($result instanceof \App\Framework\Mcp\Core\ValueObjects\ToolResult) {
$message = $result->success
? 'Operation completed successfully'
: 'Operation failed: ' . ($result->error ?? 'Unknown error');
$data = $result->data !== null ? json_encode($result->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : '';
if ($data) {
$message .= "\n" . $data;
}
return $result->success
? \App\Framework\Console\Result\TextResult::success($message)
: \App\Framework\Console\Result\TextResult::error($message);
}
// Default: convert to info text
return \App\Framework\Console\Result\TextResult::info('Command executed successfully');
}
/** /**
* Execute command with automatic parameter resolution * Execute command with automatic parameter resolution
*/ */
@@ -278,6 +329,18 @@ final readonly class CommandRegistry
$result = $method->invokeArgs($instance, $resolvedParams); $result = $method->invokeArgs($instance, $resolvedParams);
// Handle ActionResult - if it's a ConsoleResult, render it
if ($result instanceof \App\Framework\Router\ActionResult) {
if ($result instanceof \App\Framework\Console\Result\ConsoleResult) {
$result->render($progressAwareOutput);
return $result->exitCode;
}
// Convert other ActionResult types
$consoleResult = $this->convertActionResultToConsoleResult($result);
$consoleResult->render($progressAwareOutput);
return $consoleResult->exitCode;
}
return $this->normalizeCommandResult($result); return $this->normalizeCommandResult($result);
}; };

View File

@@ -23,7 +23,7 @@ use App\Framework\Discovery\Results\DiscoveryRegistry;
*/ */
final readonly class ConsoleDialog final readonly class ConsoleDialog
{ {
private bool $readlineAvailable = false; private bool $readlineAvailable;
private CommandSuggestionEngine $suggestionEngine; private CommandSuggestionEngine $suggestionEngine;

View File

@@ -4,12 +4,22 @@ declare(strict_types=1);
namespace App\Framework\Console; namespace App\Framework\Console;
use App\Framework\Attributes\Route;
use App\Framework\Core\AttributeMapper; use App\Framework\Core\AttributeMapper;
use App\Framework\Core\ParameterTypeValidator;
use App\Framework\Mcp\McpTool;
use App\Framework\Reflection\WrappedReflectionClass; use App\Framework\Reflection\WrappedReflectionClass;
use App\Framework\Reflection\WrappedReflectionMethod; use App\Framework\Reflection\WrappedReflectionMethod;
final readonly class ConsoleCommandMapper implements AttributeMapper final readonly class ConsoleCommandMapper implements AttributeMapper
{ {
private ParameterTypeValidator $typeValidator;
public function __construct()
{
$this->typeValidator = new ParameterTypeValidator();
}
public function getAttributeClass(): string public function getAttributeClass(): string
{ {
return ConsoleCommand::class; return ConsoleCommand::class;
@@ -21,6 +31,26 @@ final readonly class ConsoleCommandMapper implements AttributeMapper
return null; // ConsoleCommand can only be applied to methods return null; // ConsoleCommand can only be applied to methods
} }
// Check if method has multiple attributes (multi-purpose)
$hasMultipleAttributes = $this->hasMultiplePurposeAttributes($reflectionTarget);
// If multi-purpose, validate that all parameters are builtin types
if ($hasMultipleAttributes) {
$parameters = $reflectionTarget->getParameters()->toArray();
$reflectionParameters = [];
foreach ($parameters as $param) {
$reflectionParameters[] = $param->getType();
}
if (! $this->typeValidator->hasOnlyBuiltinParameters($reflectionParameters)) {
// Skip this attribute if parameters are not all builtin
return null;
}
}
// Get other attributes for metadata
$otherAttributes = $this->getOtherPurposeAttributes($reflectionTarget);
return [ return [
'attribute_data' => [ 'attribute_data' => [
'name' => $attributeInstance->name, 'name' => $attributeInstance->name,
@@ -28,6 +58,53 @@ final readonly class ConsoleCommandMapper implements AttributeMapper
], ],
'class' => $reflectionTarget->getDeclaringClass(), 'class' => $reflectionTarget->getDeclaringClass(),
'method' => $reflectionTarget->getName(), 'method' => $reflectionTarget->getName(),
'multi_purpose' => $hasMultipleAttributes,
'other_attributes' => $otherAttributes,
]; ];
} }
/**
* Check if method has multiple purpose attributes (McpTool, ConsoleCommand, Route)
*/
private function hasMultiplePurposeAttributes(WrappedReflectionMethod $method): bool
{
$attributes = $method->getAttributes();
$purposeAttributeCount = 0;
foreach ($attributes as $attribute) {
$attributeName = $attribute->getName();
if (in_array($attributeName, [
McpTool::class,
ConsoleCommand::class,
Route::class,
], true)) {
$purposeAttributeCount++;
}
}
return $purposeAttributeCount > 1;
}
/**
* Get other purpose attributes on the same method
*
* @return array<string>
*/
private function getOtherPurposeAttributes(WrappedReflectionMethod $method): array
{
$attributes = $method->getAttributes();
$otherAttributes = [];
foreach ($attributes as $attribute) {
$attributeName = $attribute->getName();
if (in_array($attributeName, [
McpTool::class,
Route::class,
], true)) {
$otherAttributes[] = $attributeName;
}
}
return $otherAttributes;
}
} }

View File

@@ -330,7 +330,15 @@ final readonly class MethodSignatureAnalyzer
$returnType = $method->getReturnType(); $returnType = $method->getReturnType();
if ($returnType instanceof ReflectionNamedType) { if ($returnType instanceof ReflectionNamedType) {
$returnTypeName = $returnType->getName(); $returnTypeName = $returnType->getName();
if (! in_array($returnTypeName, ['int', ExitCode::class], true)) { // Accept: int, ExitCode, ActionResult, or array
$validReturnTypes = [
'int',
ExitCode::class,
'App\Framework\MagicLinks\Actions\ActionResult',
'array'
];
if (! in_array($returnTypeName, $validReturnTypes, true)) {
return false; return false;
} }
} }

View File

@@ -2,6 +2,31 @@
Dieses Modul bietet eine flexible und benutzerfreundliche Konsolen-Schnittstelle für Ihre PHP-Anwendung. Es ermöglicht die Erstellung von CLI-Befehlen mit einfacher Eingabe- und Ausgabehandlung. Dieses Modul bietet eine flexible und benutzerfreundliche Konsolen-Schnittstelle für Ihre PHP-Anwendung. Es ermöglicht die Erstellung von CLI-Befehlen mit einfacher Eingabe- und Ausgabehandlung.
## Interaktive Modi
Das Console-Modul bietet zwei interaktive Modi:
### TUI (Text User Interface) - Standard
Grafische Terminal-UI mit Maus-Unterstützung und Navigation:
```bash
php console.php # Startet TUI (Standard)
php console.php --interactive # Startet TUI explizit
php console.php --tui # Startet TUI explizit
```
### Dialog-Modus - AI-Assistent-ähnlich
Einfache Prompt-Eingabe mit Tab-Completion und History:
```bash
php console.php --dialog # Startet Dialog-Modus
php console.php --chat # Startet Dialog-Modus (Alias)
```
**Weitere Informationen**: Siehe [Console Dialog Mode Dokumentation](../../docs/console-dialog-mode.md)
## Hauptkomponenten ## Hauptkomponenten
### ConsoleApplication ### ConsoleApplication

View File

@@ -6,6 +6,7 @@ namespace App\Framework\Console\Result;
use App\Framework\Console\ConsoleOutputInterface; use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode; use App\Framework\Console\ExitCode;
use App\Framework\Router\ActionResult;
/** /**
* Console Result Interface * Console Result Interface
@@ -18,7 +19,7 @@ use App\Framework\Console\ExitCode;
* - Rendering logic * - Rendering logic
* - Metadata for testing/introspection * - Metadata for testing/introspection
*/ */
interface ConsoleResult interface ConsoleResult extends ActionResult
{ {
/** /**
* Exit code for this result * Exit code for this result

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core;
use ReflectionIntersectionType;
use ReflectionNamedType;
use ReflectionParameter;
use ReflectionType;
use ReflectionUnionType;
/**
* Validates parameter types to check if they are builtin types
*/
final readonly class ParameterTypeValidator
{
/**
* Check if a parameter type is a builtin type
*/
public function isBuiltinType(?\ReflectionType $type): bool
{
if ($type === null) {
return false; // No type = mixed, not allowed
}
if ($type instanceof ReflectionNamedType) {
return $this->isBuiltinTypeName($type->getName());
}
if ($type instanceof ReflectionUnionType) {
return $this->isBuiltinUnionType($type);
}
if ($type instanceof ReflectionIntersectionType) {
return false; // Intersection types are not builtin
}
return false;
}
/**
* Check if all parameters of a method are builtin types
*
* @param array<\ReflectionType|null> $types
*/
public function hasOnlyBuiltinParameters(array $types): bool
{
foreach ($types as $type) {
if (! $this->isBuiltinType($type)) {
return false;
}
}
return true;
}
/**
* Check if a type name is a builtin type
*/
private function isBuiltinTypeName(string $typeName): bool
{
$builtinTypes = [
'string',
'int',
'float',
'bool',
'array',
'null',
'mixed',
];
return in_array($typeName, $builtinTypes, true);
}
/**
* Check if a union type only contains builtin types
*/
private function isBuiltinUnionType(ReflectionUnionType $type): bool
{
foreach ($type->getTypes() as $unionType) {
if (! $unionType instanceof ReflectionNamedType) {
return false;
}
$typeName = $unionType->getName();
// Allow null in unions (e.g., ?string, string|null)
if ($typeName === 'null') {
continue;
}
if (! $this->isBuiltinTypeName($typeName)) {
return false;
}
}
return true;
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Framework\Core; namespace App\Framework\Core;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Mcp\McpTool;
use App\Framework\Reflection\WrappedReflectionClass; use App\Framework\Reflection\WrappedReflectionClass;
use App\Framework\Reflection\WrappedReflectionMethod; use App\Framework\Reflection\WrappedReflectionMethod;
use App\Framework\Router\ValueObjects\MethodParameter; use App\Framework\Router\ValueObjects\MethodParameter;
@@ -12,6 +14,13 @@ use App\Framework\Router\ValueObjects\ParameterCollection;
final readonly class RouteMapper implements AttributeMapper final readonly class RouteMapper implements AttributeMapper
{ {
private ParameterTypeValidator $typeValidator;
public function __construct()
{
$this->typeValidator = new ParameterTypeValidator();
}
public function getAttributeClass(): string public function getAttributeClass(): string
{ {
return Route::class; return Route::class;
@@ -28,11 +37,44 @@ final readonly class RouteMapper implements AttributeMapper
return null; return null;
} }
// Check if method has multiple attributes (multi-purpose)
$hasMultipleAttributes = $this->hasMultiplePurposeAttributes($reflectionTarget);
// If multi-purpose, validate that all parameters are builtin types
if ($hasMultipleAttributes) {
$parameters = $reflectionTarget->getParameters()->toArray();
$reflectionParameters = [];
foreach ($parameters as $param) {
$reflectionParameters[] = $param->getType();
}
if (! $this->typeValidator->hasOnlyBuiltinParameters($reflectionParameters)) {
// Skip this attribute if parameters are not all builtin
return null;
}
}
// Collect all non-Route attributes on the method // Collect all non-Route attributes on the method
$attributes = []; $attributes = [];
$otherPurposeAttributes = [];
$hasWebhookEndpoint = false;
foreach ($reflectionTarget->getAttributes() as $attribute) { foreach ($reflectionTarget->getAttributes() as $attribute) {
if ($attribute->getName() !== Route::class) { $attributeName = $attribute->getName();
$attributes[] = $attribute->getName(); if ($attributeName !== Route::class) {
$attributes[] = $attributeName;
// Track purpose attributes
if (in_array($attributeName, [
McpTool::class,
ConsoleCommand::class,
], true)) {
$otherPurposeAttributes[] = $attributeName;
}
// Track WebhookEndpoint attribute
if ($attributeName === \App\Framework\Webhook\Attributes\WebhookEndpoint::class) {
$hasWebhookEndpoint = true;
}
} }
} }
@@ -52,6 +94,31 @@ final readonly class RouteMapper implements AttributeMapper
'parameters' => $parameterCollection->toLegacyArray(), // Backward compatibility 'parameters' => $parameterCollection->toLegacyArray(), // Backward compatibility
'parameter_collection' => $parameterCollection, // New type-safe collection 'parameter_collection' => $parameterCollection, // New type-safe collection
'attributes' => $attributes, 'attributes' => $attributes,
'multi_purpose' => $hasMultipleAttributes,
'other_attributes' => $otherPurposeAttributes,
'has_webhook_endpoint' => $hasWebhookEndpoint,
]; ];
} }
/**
* Check if method has multiple purpose attributes (McpTool, ConsoleCommand, Route)
*/
private function hasMultiplePurposeAttributes(WrappedReflectionMethod $method): bool
{
$attributes = $method->getAttributes();
$purposeAttributeCount = 0;
foreach ($attributes as $attribute) {
$attributeName = $attribute->getName();
if (in_array($attributeName, [
McpTool::class,
ConsoleCommand::class,
Route::class,
], true)) {
$purposeAttributeCount++;
}
}
return $purposeAttributeCount > 1;
}
} }

View File

@@ -284,6 +284,39 @@ final readonly class ClassName implements Stringable
private function isValidClassName(string $className): bool private function isValidClassName(string $className): bool
{ {
// Basic validation: should contain only alphanumeric, underscore, and backslash // Basic validation: should contain only alphanumeric, underscore, and backslash
return preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff\\\\]*$/', $className) === 1; if (preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff\\\\]*$/', $className) !== 1) {
return false;
}
// Extract the short name (last part after backslash)
$lastBackslash = strrpos($className, '\\');
$shortName = $lastBackslash === false ? $className : substr($className, $lastBackslash + 1);
// Reject known invalid class names (PHP keywords and common false positives)
$invalidNames = [
'implementation', 'interface', 'extends', 'implements',
'class', 'trait', 'enum', 'namespace', 'use', 'as',
'public', 'private', 'protected', 'static', 'final', 'abstract',
'function', 'return', 'if', 'else', 'foreach', 'while', 'for',
'true', 'false', 'null', 'void', 'mixed', 'array', 'object',
'string', 'int', 'float', 'bool', 'resource', 'callable', 'iterable'
];
if (in_array(strtolower($shortName), $invalidNames, true)) {
return false;
}
// Reject camelCase names (starting with lowercase) - class names should be PascalCase
// camelCase names are almost always methods, functions, or variables, not classes
if (preg_match('/^[a-z][a-zA-Z0-9]*$/', $shortName) && ! str_contains($shortName, '_') && ! str_contains($shortName, '\\')) {
// This is camelCase - reject it as a class name
// Only allow if it's a known valid exception (very rare in PHP)
$validCamelCaseExceptions = [];
if (! in_array($shortName, $validCamelCaseExceptions, true)) {
return false;
}
}
return true;
} }
} }

View File

@@ -12,7 +12,6 @@ use App\Framework\Database\Driver\Optimization\SQLiteOptimizer;
use App\Framework\Database\Profiling\ProfilingDashboard; use App\Framework\Database\Profiling\ProfilingDashboard;
use App\Framework\Database\Profiling\QueryProfiler; use App\Framework\Database\Profiling\QueryProfiler;
use App\Framework\Database\Profiling\SlowQueryDetector; use App\Framework\Database\Profiling\SlowQueryDetector;
use App\Framework\Http\Controller;
use App\Framework\Http\Response\JsonResponse; use App\Framework\Http\Response\JsonResponse;
use App\Framework\Http\Response\Response; use App\Framework\Http\Response\Response;
use App\Framework\Http\Response\ViewResponse; use App\Framework\Http\Response\ViewResponse;
@@ -21,7 +20,7 @@ use App\Framework\View\ViewRenderer;
/** /**
* Controller for the database performance dashboard * Controller for the database performance dashboard
*/ */
final readonly class DatabaseDashboardController implements Controller final readonly class DatabaseDashboardController
{ {
public function __construct( public function __construct(
private DatabaseManager $databaseManager, private DatabaseManager $databaseManager,

View File

@@ -7,7 +7,6 @@ namespace App\Framework\Database\Monitoring\Dashboard;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
use App\Framework\Database\DatabaseManager; use App\Framework\Database\DatabaseManager;
use App\Framework\Database\Monitoring\Health\DatabaseHealthChecker; use App\Framework\Database\Monitoring\Health\DatabaseHealthChecker;
use App\Framework\Http\Controller;
use App\Framework\Http\Response\JsonResponse; use App\Framework\Http\Response\JsonResponse;
use App\Framework\Http\Response\Response; use App\Framework\Http\Response\Response;
use App\Framework\Http\Response\ViewResponse; use App\Framework\Http\Response\ViewResponse;
@@ -16,7 +15,7 @@ use App\Framework\View\ViewRenderer;
/** /**
* Controller for the database health dashboard * Controller for the database health dashboard
*/ */
final readonly class DatabaseHealthController implements Controller final readonly class DatabaseHealthController
{ {
public function __construct( public function __construct(
private DatabaseManager $databaseManager, private DatabaseManager $databaseManager,

View File

@@ -6,7 +6,6 @@ namespace App\Framework\Database\Monitoring\Dashboard;
use App\Framework\Attributes\Route; use App\Framework\Attributes\Route;
use App\Framework\Database\Monitoring\History\QueryHistoryLogger; use App\Framework\Database\Monitoring\History\QueryHistoryLogger;
use App\Framework\Http\Controller;
use App\Framework\Http\Response\JsonResponse; use App\Framework\Http\Response\JsonResponse;
use App\Framework\Http\Response\Response; use App\Framework\Http\Response\Response;
use App\Framework\Http\Response\ViewResponse; use App\Framework\Http\Response\ViewResponse;
@@ -15,7 +14,7 @@ use App\Framework\View\ViewRenderer;
/** /**
* Controller for displaying historical query performance data * Controller for displaying historical query performance data
*/ */
final readonly class QueryHistoryController implements Controller final readonly class QueryHistoryController
{ {
public function __construct( public function __construct(
private QueryHistoryLogger $historyLogger, private QueryHistoryLogger $historyLogger,

View File

@@ -38,6 +38,7 @@ final readonly class ClassExtractor
} }
$classes = $this->tokenizer->extractClasses($content); $classes = $this->tokenizer->extractClasses($content);
$fileNamespace = $this->extractFileNamespace($content);
$validClassNames = []; $validClassNames = [];
foreach ($classes as $class) { foreach ($classes as $class) {
@@ -52,6 +53,22 @@ final readonly class ClassExtractor
continue; continue;
} }
// CRITICAL: Validate that the extracted class belongs to this file's namespace
// Classes should only be extracted from files where they are declared,
// not from files where they are only used (via use statements)
if (! $this->belongsToFileNamespace($fqn, $fileNamespace, $class['namespace'] ?? null)) {
continue;
}
// ADDITIONAL SAFETY: Verify that the class is actually declared in this file
// Search for "class <name>" or "interface <name>" etc. in the file content
// This catches cases where the tokenizer might extract wrong names
$shortClassName = $class['name'] ?? null;
if ($shortClassName !== null && ! $this->isClassDeclaredInFile($content, $shortClassName, $class['type'] ?? 'class')) {
// Class name found but not actually declared in this file - skip it
continue;
}
try { try {
$className = ClassName::create($fqn); $className = ClassName::create($fqn);
$validClassNames[] = $className; $validClassNames[] = $className;
@@ -96,30 +113,107 @@ final readonly class ClassExtractor
// Single lowercase word is suspicious - likely a property/method name // Single lowercase word is suspicious - likely a property/method name
// But we'll be conservative and only reject known problematic names // But we'll be conservative and only reject known problematic names
$knownInvalid = ['state', 'container', 'get', 'set', 'map', 'compile', 'install', 'shouldretry', 'additionaldata']; $knownInvalid = [
'state', 'container', 'get', 'set', 'map', 'compile', 'install',
'shouldretry', 'additionaldata',
// PHP keywords and common false positives from class extraction
'implementation', 'interface', 'extends', 'implements'
];
if (in_array(strtolower($shortName), $knownInvalid, true)) { if (in_array(strtolower($shortName), $knownInvalid, true)) {
return false; return false;
} }
} }
// camelCase starting with lowercase (likely method names) // camelCase starting with lowercase (likely method names) - STRICT REJECTION
if (preg_match('/^[a-z][a-zA-Z0-9]*$/', $shortName) && ! str_contains($shortName, '\\')) { // PHP class names should be PascalCase (start with uppercase)
$methodPrefixes = ['get', 'set', 'is', 'has', 'should', 'can', 'will', 'do', 'add', 'remove', 'update', 'delete']; // camelCase names are almost always methods, functions, or variables
$lowercase = strtolower($shortName); if (preg_match('/^[a-z][a-zA-Z0-9]*$/', $shortName) && ! str_contains($shortName, '\\') && ! str_contains($shortName, '_')) {
foreach ($methodPrefixes as $prefix) { // This is camelCase - reject it as a class name
if (str_starts_with($lowercase, $prefix) && strlen($shortName) > strlen($prefix)) { // Only allow if it's a known valid exception (very rare)
// Check if it's in our known invalid list $validCamelCaseExceptions = [];
$knownInvalid = ['shouldretry', 'additionaldata']; if (! in_array($shortName, $validCamelCaseExceptions, true)) {
if (in_array($lowercase, $knownInvalid, true)) { return false;
return false;
}
}
} }
} }
return true; return true;
} }
/**
* Extract namespace from file content
*/
private function extractFileNamespace(string $content): ?string
{
// Extract namespace from file using regex
if (preg_match('/namespace\s+([^;]+);/', $content, $matches)) {
return trim($matches[1]);
}
return null;
}
/**
* Validate that extracted class belongs to the file's namespace
* This prevents extracting classes that are only used (via use statements) but not declared
*/
private function belongsToFileNamespace(string $fqn, ?string $fileNamespace, ?string $extractedNamespace): bool
{
// If file has no namespace (global namespace), only classes without namespace should be extracted
if ($fileNamespace === null || $fileNamespace === '') {
// Check if FQN has no namespace (global namespace) - no backslash or only one at start
return ! str_contains($fqn, '\\') || (str_starts_with($fqn, '\\') && substr_count($fqn, '\\') === 1);
}
// Extract namespace from FQN
$lastBackslash = strrpos($fqn, '\\');
if ($lastBackslash === false) {
// Class name without namespace - in a namespaced file, this shouldn't happen
// But if it does, it's invalid (classes in namespaced files should have namespace)
return false;
}
$fqnNamespace = substr($fqn, 0, $lastBackslash);
// Primary validation: Use the extracted namespace from tokenizer if available
// This is the most reliable source as it comes directly from the namespace declaration
// CRITICAL: Must match EXACTLY, not just start with
if ($extractedNamespace !== null && $extractedNamespace !== '') {
// Exact match required - no sub-namespaces allowed
if ($extractedNamespace !== $fileNamespace) {
return false;
}
// Also validate that FQN namespace matches
return $fqnNamespace === $fileNamespace;
}
// Fallback: Check if FQN namespace matches file namespace EXACTLY
// This ensures the full qualified name matches the file's namespace
// No partial matches - must be identical
return $fqnNamespace === $fileNamespace;
}
/**
* Check if a class is actually declared in the file content
* This prevents extracting classes that are only used (via use statements) but not declared
*/
private function isClassDeclaredInFile(string $content, string $className, string $type): bool
{
// Build pattern to match class/interface/trait/enum declarations
$keyword = match($type) {
'class' => 'class',
'interface' => 'interface',
'trait' => 'trait',
'enum' => 'enum',
default => 'class'
};
// Match: optional modifiers (final, abstract, readonly) + keyword + whitespace + class name
// Must be followed by whitespace, {, or extends/implements
$pattern = '/\b(?:final\s+|abstract\s+|readonly\s+)*' . preg_quote($keyword, '/') . '\s+' . preg_quote($className, '/') . '(?:\s|{|extends|implements)/';
return preg_match($pattern, $content) === 1;
}
/** /**
* Check if content contains actual PHP code * Check if content contains actual PHP code
*/ */

View File

@@ -38,6 +38,7 @@ final readonly class FileStreamProcessor
callable $fileProcessor callable $fileProcessor
): int { ): int {
$totalFiles = 0; $totalFiles = 0;
$failedFiles = [];
error_log("FileStreamProcessor: Processing directories: " . implode(', ', $directories)); error_log("FileStreamProcessor: Processing directories: " . implode(', ', $directories));
@@ -45,6 +46,7 @@ final readonly class FileStreamProcessor
$directoryPath = FilePath::create($directory); $directoryPath = FilePath::create($directory);
foreach ($this->streamPhpFiles($directoryPath) as $file) { foreach ($this->streamPhpFiles($directoryPath) as $file) {
$fileContext = null;
try { try {
// Extract classes from file // Extract classes from file
$classNames = $this->classExtractor->extractFromFile($file); $classNames = $this->classExtractor->extractFromFile($file);
@@ -67,11 +69,13 @@ final readonly class FileStreamProcessor
$this->processingContext->maybeCollectGarbage($totalFiles); $this->processingContext->maybeCollectGarbage($totalFiles);
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Only log errors, not every processed file // Collect error information for aggregated reporting
$this->logger?->warning( $filePath = $file->getPath()->toString();
"Failed to process file {$file->getPath()->toString()}: {$e->getMessage()} in FileStreamProcessor", $failedFiles[] = [
LogContext::withException($e) 'file' => $filePath,
); 'error' => $e->getMessage(),
'type' => $e::class,
];
} finally { } finally {
// Always cleanup after processing a file // Always cleanup after processing a file
$this->processingContext->cleanup(); $this->processingContext->cleanup();
@@ -79,6 +83,28 @@ final readonly class FileStreamProcessor
} }
} }
// Log aggregated summary instead of individual errors
if (!empty($failedFiles)) {
$failedCount = count($failedFiles);
$errorMessage = sprintf(
'Failed to read %d file(s) because of syntax errors or other issues.',
$failedCount
);
$context = LogContext::create()->withData([
'failed_files_count' => $failedCount,
'total_files_processed' => $totalFiles,
'failed_files' => array_map(fn($f) => [
'file' => $f['file'],
'error' => $f['error'],
'type' => $f['type'],
], $failedFiles)
]);
$this->logger?->warning($errorMessage, $context);
error_log("FileStreamProcessor: {$errorMessage}");
}
error_log("FileStreamProcessor: Total files processed: " . $totalFiles); error_log("FileStreamProcessor: Total files processed: " . $totalFiles);
return $totalFiles; return $totalFiles;

View File

@@ -16,6 +16,7 @@ use App\Framework\Discovery\ValueObjects\TemplateMapping;
use App\Framework\Filesystem\File; use App\Framework\Filesystem\File;
use App\Framework\Http\Method; use App\Framework\Http\Method;
use App\Framework\Logging\Logger; use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
/** /**
* Coordinates visitor execution with shared reflection context * Coordinates visitor execution with shared reflection context
@@ -72,19 +73,41 @@ final class VisitorCoordinator
FileContext $fileContext, FileContext $fileContext,
DiscoveryDataCollector $collector DiscoveryDataCollector $collector
): void { ): void {
// Get shared reflection instance try {
$reflection = $this->processingContext->getReflection($className); // Get shared reflection instance
if ($reflection === null) { $reflection = $this->processingContext->getReflection($className);
return; if ($reflection === null) {
} return;
}
// Process attributes // Process attributes
$this->processClassAttributes($className, $fileContext, $reflection, $collector); $this->processClassAttributes($className, $fileContext, $reflection, $collector);
$this->processMethodAttributes($className, $fileContext, $reflection, $collector); $this->processMethodAttributes($className, $fileContext, $reflection, $collector);
// Process interface implementations // Process interface implementations
if (! empty($this->targetInterfaces)) { if (! empty($this->targetInterfaces)) {
$this->processInterfaces($className, $reflection, $collector); $this->processInterfaces($className, $reflection, $collector);
}
} catch (\Throwable $e) {
$errorMessage = sprintf(
'Failed to process class "%s" in file %s: %s',
$className->getFullyQualified(),
$fileContext->path->toString(),
$e->getMessage()
);
$context = LogContext::withException($e)->withData([
'class_name' => $className->getFullyQualified(),
'file_path' => $fileContext->path->toString(),
'exception_class' => $e::class,
'exception_file' => $e->getFile(),
'exception_line' => $e->getLine(),
]);
$this->logger?->warning($errorMessage, $context);
error_log($errorMessage);
// Don't re-throw - continue processing other classes
} }
} }
@@ -130,29 +153,78 @@ final class VisitorCoordinator
$reflection, $reflection,
DiscoveryDataCollector $collector DiscoveryDataCollector $collector
): void { ): void {
foreach ($reflection->getMethods() as $method) { try {
foreach ($method->getAttributes() as $attribute) { $methods = $reflection->getMethods();
$attributeClass = $attribute->getName(); } catch (\Throwable $e) {
$errorMessage = sprintf(
'Failed to get methods for class "%s" in file %s: %s',
$className->getFullyQualified(),
$fileContext->path->toString(),
$e->getMessage()
);
$context = LogContext::withException($e)->withData([
'class_name' => $className->getFullyQualified(),
'file_path' => $fileContext->path->toString(),
'exception_class' => $e::class,
'exception_file' => $e->getFile(),
'exception_line' => $e->getLine(),
]);
$this->logger?->warning($errorMessage, $context);
error_log($errorMessage);
// Return early if we can't get methods
return;
}
foreach ($methods as $method) {
try {
foreach ($method->getAttributes() as $attribute) {
$attributeClass = $attribute->getName();
if ($this->shouldIgnoreAttribute($attributeClass)) { if ($this->shouldIgnoreAttribute($attributeClass)) {
continue; continue;
}
$mappedData = $this->applyMapper($attributeClass, $method, $attribute);
$discovered = new DiscoveredAttribute(
className: $className,
attributeClass: $attributeClass,
target: AttributeTarget::METHOD,
methodName: MethodName::create($method->getName()),
propertyName: null,
arguments: $this->extractAttributeArguments($attribute),
filePath: $fileContext->path,
additionalData: $mappedData ?? []
);
$collector->getAttributeRegistry()->add($attributeClass, $discovered);
} }
} catch (\Throwable $e) {
$errorMessage = sprintf(
$mappedData = $this->applyMapper($attributeClass, $method, $attribute); 'Failed to process method "%s" in class "%s" in file %s: %s',
$method->getName(),
$discovered = new DiscoveredAttribute( $className->getFullyQualified(),
className: $className, $fileContext->path->toString(),
attributeClass: $attributeClass, $e->getMessage()
target: AttributeTarget::METHOD,
methodName: MethodName::create($method->getName()),
propertyName: null,
arguments: $this->extractAttributeArguments($attribute),
filePath: $fileContext->path,
additionalData: $mappedData ?? []
); );
$collector->getAttributeRegistry()->add($attributeClass, $discovered); $context = LogContext::withException($e)->withData([
'class_name' => $className->getFullyQualified(),
'method_name' => $method->getName(),
'file_path' => $fileContext->path->toString(),
'exception_class' => $e::class,
'exception_file' => $e->getFile(),
'exception_line' => $e->getLine(),
]);
$this->logger?->warning($errorMessage, $context);
error_log($errorMessage);
// Continue with next method
continue;
} }
} }
} }

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Framework\Examples;
use App\Framework\Attributes\Route;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Http\Method;
use App\Framework\Mcp\McpTool;
use App\Framework\Router\ActionResult;
use App\Framework\Router\Result\JsonResult;
/**
* Example implementation of a multi-purpose action
*
* This class demonstrates how a single method can be used as:
* - MCP Tool (for AI integration)
* - Console Command (for CLI usage)
* - HTTP Route (for API access)
*
* Requirements:
* - Only builtin parameter types (string, int, bool, float, array)
* - Returns ActionResult (unified return type)
*/
final readonly class MultiPurposeAction
{
/**
* List users with filtering and pagination
*
* This method can be called via:
* - MCP: {"name": "list_users", "arguments": {"status": "active", "limit": 10}}
* - Console: users:list --status=active --limit=10
* - HTTP: GET /api/users?status=active&limit=10
*/
#[McpTool(
name: 'list_users',
description: 'List users with optional filtering and pagination'
)]
#[ConsoleCommand(
name: 'users:list',
description: 'List users with optional filtering and pagination'
)]
#[Route(
path: '/api/users',
method: Method::GET
)]
public function listUsers(
string $status = 'active',
int $limit = 10,
bool $includeDetails = false
): ActionResult {
// Simulate user data
$users = [
['id' => 1, 'name' => 'John Doe', 'status' => 'active'],
['id' => 2, 'name' => 'Jane Smith', 'status' => 'active'],
['id' => 3, 'name' => 'Bob Johnson', 'status' => 'inactive'],
];
// Filter by status
$filteredUsers = array_filter($users, fn($user) => $user['status'] === $status);
// Limit results
$limitedUsers = array_slice($filteredUsers, 0, $limit);
// Add details if requested
if ($includeDetails) {
foreach ($limitedUsers as &$user) {
$user['email'] = strtolower(str_replace(' ', '.', $user['name'])) . '@example.com';
$user['created_at'] = '2024-01-01';
}
}
// Return unified ActionResult
return new JsonResult([
'users' => array_values($limitedUsers),
'total' => count($limitedUsers),
'status' => $status,
'limit' => $limit,
]);
}
/**
* Get user by ID
*
* This method can be called via:
* - MCP: {"name": "get_user", "arguments": {"userId": "1"}}
* - Console: users:get --userId=1
* - HTTP: GET /api/users/1
*/
#[McpTool(
name: 'get_user',
description: 'Get a specific user by ID'
)]
#[ConsoleCommand(
name: 'users:get',
description: 'Get a specific user by ID'
)]
#[Route(
path: '/api/users/{userId}',
method: Method::GET
)]
public function getUser(int $userId): ActionResult
{
// Simulate user data
$users = [
1 => ['id' => 1, 'name' => 'John Doe', 'status' => 'active', 'email' => 'john.doe@example.com'],
2 => ['id' => 2, 'name' => 'Jane Smith', 'status' => 'active', 'email' => 'jane.smith@example.com'],
3 => ['id' => 3, 'name' => 'Bob Johnson', 'status' => 'inactive', 'email' => 'bob.johnson@example.com'],
];
if (! isset($users[$userId])) {
return new JsonResult([
'error' => 'User not found',
'user_id' => $userId,
]);
}
return new JsonResult([
'user' => $users[$userId],
]);
}
/**
* Create a new user
*
* This method can be called via:
* - MCP: {"name": "create_user", "arguments": {"name": "Alice", "email": "alice@example.com"}}
* - Console: users:create --name=Alice --email=alice@example.com
* - HTTP: POST /api/users with JSON body
*/
#[McpTool(
name: 'create_user',
description: 'Create a new user'
)]
#[ConsoleCommand(
name: 'users:create',
description: 'Create a new user'
)]
#[Route(
path: '/api/users',
method: Method::POST
)]
public function createUser(
string $name,
string $email,
bool $active = true
): ActionResult {
// Simulate user creation
$newUser = [
'id' => 4,
'name' => $name,
'email' => $email,
'status' => $active ? 'active' : 'inactive',
'created_at' => date('Y-m-d H:i:s'),
];
return new JsonResult([
'user' => $newUser,
'message' => 'User created successfully',
]);
}
}

View File

@@ -99,7 +99,7 @@ final readonly class WafMiddleware implements HttpMiddleware
// Debug log analysis result // Debug log analysis result
$this->logger->debug('WAF analysis complete', LogContext::withData([ $this->logger->debug('WAF analysis complete', LogContext::withData([
'result_status' => $wafResult->getStatus()->value ?? 'unknown', 'result_status' => $wafResult->status->value ?? 'unknown',
'result_action' => $wafResult->getAction(), 'result_action' => $wafResult->getAction(),
'layer_name' => $wafResult->getLayerName(), 'layer_name' => $wafResult->getLayerName(),
'message' => $wafResult->getMessage(), 'message' => $wafResult->getMessage(),

View File

@@ -58,6 +58,148 @@ final readonly class CurlHttpClient implements HttpClient
// Wrap any exception in CurlExecutionFailed for backward compatibility // Wrap any exception in CurlExecutionFailed for backward compatibility
throw new CurlExecutionFailed($e->getMessage(), $e->getCode(), $e); throw new CurlExecutionFailed($e->getMessage(), $e->getCode(), $e);
} }
}
/**
* Send request with streaming response to destination
*
* Streams HTTP response directly to a writable stream resource.
* Useful for large file downloads without loading entire response into memory.
*
* @param ClientRequest $request HTTP request to send
* @param resource $destination Writable stream resource (e.g., fopen('file.txt', 'w'))
* @return StreamingResponse Response with headers and status, but no body (streamed to destination)
* @throws CurlExecutionFailed If request execution fails
*/
public function sendStreaming(ClientRequest $request, $destination): StreamingResponse
{
if (! is_resource($destination)) {
throw new \InvalidArgumentException('Destination must be a valid stream resource');
}
$handle = new Handle();
try {
// Build options using HandleOption enum
$options = $this->requestBuilder->buildOptions($request);
// Remove CURLOPT_RETURNTRANSFER (we're streaming to destination)
unset($options[\App\Framework\HttpClient\Curl\HandleOption::ReturnTransfer->value]);
// Handle authentication
if ($request->options->auth !== null) {
$authResult = $this->authenticationHandler->configure($request->options->auth, $request->headers);
if ($authResult->headers !== $request->headers) {
$updatedRequest = $request->with(['headers' => $authResult->headers]);
$options = $this->requestBuilder->buildOptions($updatedRequest);
unset($options[\App\Framework\HttpClient\Curl\HandleOption::ReturnTransfer->value]);
}
if (! empty($authResult->curlOptions)) {
$options = array_replace($options, $authResult->curlOptions);
}
}
// Enable header capture for streaming
$headerBuffer = '';
$headerFunction = function ($ch, $header) use (&$headerBuffer) {
$headerBuffer .= $header;
return strlen($header);
};
$options[\App\Framework\HttpClient\Curl\HandleOption::HeaderFunction->value] = $headerFunction;
// Set all options
$handle->setOptions($options);
// Execute and stream directly to destination
$handle->execute($destination);
// Parse response headers from buffer
$statusCode = $handle->getInfo(\App\Framework\HttpClient\Curl\Info::ResponseCode);
$status = \App\Framework\Http\Status::from((int) $statusCode);
$headers = $this->responseParser->parseHeaders($headerBuffer);
// Get bytes written (if available)
$bytesWritten = (int) ($handle->getInfo(\App\Framework\HttpClient\Curl\Info::SizeDownload) ?: 0);
return new StreamingResponse(
status: $status,
headers: $headers,
bytesWritten: $bytesWritten
);
} catch (\Throwable $e) {
throw new CurlExecutionFailed($e->getMessage(), $e->getCode(), $e);
}
}
/**
* Send request with streaming body from source
*
* Streams HTTP request body from a readable stream resource.
* Useful for large file uploads without loading entire file into memory.
*
* @param ClientRequest $request HTTP request to send (body will be replaced by stream)
* @param resource $source Readable stream resource (e.g., fopen('file.txt', 'r'))
* @param int|null $contentLength Content-Length in bytes (null for chunked transfer)
* @return ClientResponse HTTP response
* @throws CurlExecutionFailed If request execution fails
*/
public function sendStreamingUpload(ClientRequest $request, $source, ?int $contentLength = null): ClientResponse
{
if (! is_resource($source)) {
throw new \InvalidArgumentException('Source must be a valid stream resource');
}
$handle = new Handle();
try {
// Build options using HandleOption enum
$options = $this->requestBuilder->buildOptions($request);
// Remove CURLOPT_POSTFIELDS (we're using stream)
unset($options[\App\Framework\HttpClient\Curl\HandleOption::PostFields->value]);
// Set streaming upload options
$options[\App\Framework\HttpClient\Curl\HandleOption::Upload->value] = true;
$options[\App\Framework\HttpClient\Curl\HandleOption::InFile->value] = $source;
if ($contentLength !== null) {
$options[\App\Framework\HttpClient\Curl\HandleOption::InFileSize->value] = $contentLength;
}
// Handle authentication
if ($request->options->auth !== null) {
$authResult = $this->authenticationHandler->configure($request->options->auth, $request->headers);
if ($authResult->headers !== $request->headers) {
$updatedRequest = $request->with(['headers' => $authResult->headers]);
$options = array_replace($options, $this->requestBuilder->buildOptions($updatedRequest));
unset($options[\App\Framework\HttpClient\Curl\HandleOption::PostFields->value]);
$options[\App\Framework\HttpClient\Curl\HandleOption::Upload->value] = true;
$options[\App\Framework\HttpClient\Curl\HandleOption::InFile->value] = $source;
if ($contentLength !== null) {
$options[\App\Framework\HttpClient\Curl\HandleOption::InFileSize->value] = $contentLength;
}
}
if (! empty($authResult->curlOptions)) {
$options = array_replace($options, $authResult->curlOptions);
}
}
// Set all options
$handle->setOptions($options);
// Execute request (automatically uses CURLOPT_RETURNTRANSFER)
$rawResponse = $handle->fetch();
// Parse response
return $this->responseParser->parse($rawResponse, $handle->getResource());
} catch (\Throwable $e) {
throw new CurlExecutionFailed($e->getMessage(), $e->getCode(), $e);
}
}
@@ -127,6 +269,7 @@ final readonly class CurlHttpClient implements HttpClient
status: Status::from($status), status: Status::from($status),
headers: $headers, headers: $headers,
body: $body body: $body
);*/ );
} }
*/
} }

View File

@@ -23,7 +23,7 @@ final readonly class CurlResponseParser
return new ClientResponse($status, $headers, $body); return new ClientResponse($status, $headers, $body);
} }
private function parseHeaders(string $headersRaw): Headers public function parseHeaders(string $headersRaw): Headers
{ {
$headers = new Headers(); $headers = new Headers();
$lines = explode("\r\n", trim($headersRaw)); $lines = explode("\r\n", trim($headersRaw));

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\HttpClient;
use App\Framework\Http\Headers;
use App\Framework\Http\Status;
/**
* Response from streaming HTTP request
*
* Contains status and headers, but no body (body was streamed to destination).
* Includes bytesWritten count for verification.
*/
final readonly class StreamingResponse
{
public function __construct(
public Status $status,
public Headers $headers,
public int $bytesWritten = 0
) {
}
public function isSuccessful(): bool
{
return $this->status->isSuccess();
}
}

View File

@@ -4,29 +4,36 @@ declare(strict_types=1);
namespace App\Framework\Logging\Handlers; namespace App\Framework\Logging\Handlers;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel; use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ValueObjects\LogContext; use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Logging\ValueObjects\LogEntry;
/** /**
* In-memory log handler for testing * In-memory log handler for testing
* *
* Stores all log entries in memory for inspection and assertions in tests * Stores all log records in memory for inspection and assertions in tests
*/ */
final class InMemoryHandler implements LogHandler final class InMemoryHandler implements LogHandler
{ {
/** @var LogEntry[] */ /** @var LogRecord[] */
private array $entries = []; private array $entries = [];
public function handle(LogEntry $entry): void public function isHandling(LogRecord $record): bool
{ {
$this->entries[] = $entry; // In-memory handler captures all log records for testing
return true;
}
public function handle(LogRecord $record): void
{
$this->entries[] = $record;
} }
/** /**
* Get all logged entries * Get all logged entries
* *
* @return LogEntry[] * @return LogRecord[]
*/ */
public function getEntries(): array public function getEntries(): array
{ {
@@ -36,13 +43,13 @@ final class InMemoryHandler implements LogHandler
/** /**
* Get entries by log level * Get entries by log level
* *
* @return LogEntry[] * @return LogRecord[]
*/ */
public function getEntriesByLevel(LogLevel $level): array public function getEntriesByLevel(LogLevel $level): array
{ {
return array_filter( return array_filter(
$this->entries, $this->entries,
fn(LogEntry $entry) => $entry->level === $level fn(LogRecord $entry) => $entry->level === $level
); );
} }

View File

@@ -36,7 +36,7 @@ final class PerformanceProcessor implements LogProcessor
} }
} }
public function process(LogRecord $record): LogRecord public function processRecord(LogRecord $record): LogRecord
{ {
$performance = []; $performance = [];
@@ -54,3 +54,22 @@ final class PerformanceProcessor implements LogProcessor
$elapsed = Timestamp::now()->diffInMilliseconds(self::$requestStartTime); $elapsed = Timestamp::now()->diffInMilliseconds(self::$requestStartTime);
$performance['execution_time_ms'] = round($elapsed, 2); $performance['execution_time_ms'] = round($elapsed, 2);
} }
// Merge performance data into log record context
return $record->withContext(array_merge(
$record->context,
['performance' => $performance]
));
}
public function getPriority(): int
{
// Low priority - add performance data at the end
return 10;
}
public function getName(): string
{
return 'performance';
}
}

View File

@@ -4,12 +4,14 @@ declare(strict_types=1);
namespace App\Framework\Mcp\Core\ValueObjects; namespace App\Framework\Mcp\Core\ValueObjects;
use App\Framework\Router\ActionResult;
/** /**
* Value Object für MCP Tool Ergebnisse * Value Object für MCP Tool Ergebnisse
* *
* Standardisiert die Rückgabe aller MCP Tools * Standardisiert die Rückgabe aller MCP Tools
*/ */
final readonly class ToolResult final readonly class ToolResult implements ActionResult
{ {
public function __construct( public function __construct(
public mixed $data, public mixed $data,

View File

@@ -110,13 +110,24 @@ final readonly class McpServer
$result = $instance->$method(...$this->prepareArguments($tool['parameters'], $arguments)); $result = $instance->$method(...$this->prepareArguments($tool['parameters'], $arguments));
// Handle ActionResult - convert to ToolResult if needed
if ($result instanceof \App\Framework\Router\ActionResult) {
$toolResult = $this->convertActionResultToToolResult($result);
} elseif ($result instanceof \App\Framework\Mcp\Core\ValueObjects\ToolResult) {
$toolResult = $result;
} else {
// Convert plain result to ToolResult
$toolResult = \App\Framework\Mcp\Core\ValueObjects\ToolResult::success($result);
}
// Convert ToolResult to MCP response
$response = [ $response = [
'jsonrpc' => '2.0', 'jsonrpc' => '2.0',
'result' => [ 'result' => [
'content' => [ 'content' => [
[ [
'type' => 'text', 'type' => 'text',
'text' => is_string($result) ? $result : json_encode($result, JSON_PRETTY_PRINT), 'text' => json_encode($toolResult->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
], ],
], ],
], ],
@@ -130,6 +141,35 @@ final readonly class McpServer
} }
} }
/**
* Convert ActionResult to ToolResult
*/
private function convertActionResultToToolResult(\App\Framework\Router\ActionResult $result): \App\Framework\Mcp\Core\ValueObjects\ToolResult
{
// If it's already a ToolResult, return it
if ($result instanceof \App\Framework\Mcp\Core\ValueObjects\ToolResult) {
return $result;
}
// Convert JsonResult to ToolResult
if ($result instanceof \App\Framework\Router\Result\JsonResult) {
return \App\Framework\Mcp\Core\ValueObjects\ToolResult::success($result->data);
}
// Convert ConsoleResult to ToolResult
if ($result instanceof \App\Framework\Console\Result\ConsoleResult) {
$success = $result->exitCode->value === 0;
$message = $result->data['message'] ?? ($success ? 'Command executed successfully' : 'Command failed');
return $success
? \App\Framework\Mcp\Core\ValueObjects\ToolResult::success($result->data, ['message' => $message])
: \App\Framework\Mcp\Core\ValueObjects\ToolResult::failure($message, $result->data);
}
// Default: convert to success ToolResult
return \App\Framework\Mcp\Core\ValueObjects\ToolResult::success(['result' => 'Operation completed']);
}
private function listResources($requestId = null): string private function listResources($requestId = null): string
{ {
$resources = []; $resources = [];

View File

@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace App\Framework\Mcp; namespace App\Framework\Mcp;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Core\AttributeMapper; use App\Framework\Core\AttributeMapper;
use App\Framework\Core\ParameterTypeValidator;
use App\Framework\Attributes\Route;
use App\Framework\Mcp\Core\ValueObjects\OutputFormat; use App\Framework\Mcp\Core\ValueObjects\OutputFormat;
use App\Framework\Reflection\WrappedReflectionClass; use App\Framework\Reflection\WrappedReflectionClass;
use App\Framework\Reflection\WrappedReflectionMethod; use App\Framework\Reflection\WrappedReflectionMethod;
@@ -15,6 +18,13 @@ use ReflectionUnionType;
final readonly class McpToolMapper implements AttributeMapper final readonly class McpToolMapper implements AttributeMapper
{ {
private ParameterTypeValidator $typeValidator;
public function __construct()
{
$this->typeValidator = new ParameterTypeValidator();
}
public function getAttributeClass(): string public function getAttributeClass(): string
{ {
return McpTool::class; return McpTool::class;
@@ -28,6 +38,26 @@ final readonly class McpToolMapper implements AttributeMapper
$class = $reflectionTarget->getDeclaringClass(); $class = $reflectionTarget->getDeclaringClass();
// Check if method has multiple attributes (multi-purpose)
$hasMultipleAttributes = $this->hasMultiplePurposeAttributes($reflectionTarget);
// If multi-purpose, validate that all parameters are builtin types
if ($hasMultipleAttributes) {
$parameters = $reflectionTarget->getParameters()->toArray();
$reflectionParameters = [];
foreach ($parameters as $param) {
$reflectionParameters[] = $param->getType();
}
if (! $this->typeValidator->hasOnlyBuiltinParameters($reflectionParameters)) {
// Skip this attribute if parameters are not all builtin
return null;
}
}
// Get other attributes for metadata
$otherAttributes = $this->getOtherPurposeAttributes($reflectionTarget);
return [ return [
'name' => $attributeInstance->name, 'name' => $attributeInstance->name,
'description' => $attributeInstance->description, 'description' => $attributeInstance->description,
@@ -35,9 +65,56 @@ final readonly class McpToolMapper implements AttributeMapper
'class' => $class->getFullyQualified(), 'class' => $class->getFullyQualified(),
'method' => $reflectionTarget->getName(), 'method' => $reflectionTarget->getName(),
'parameters' => $this->extractParameters($reflectionTarget), 'parameters' => $this->extractParameters($reflectionTarget),
'multi_purpose' => $hasMultipleAttributes,
'other_attributes' => $otherAttributes,
]; ];
} }
/**
* Check if method has multiple purpose attributes (McpTool, ConsoleCommand, Route)
*/
private function hasMultiplePurposeAttributes(WrappedReflectionMethod $method): bool
{
$attributes = $method->getAttributes();
$purposeAttributeCount = 0;
foreach ($attributes as $attribute) {
$attributeName = $attribute->getName();
if (in_array($attributeName, [
McpTool::class,
ConsoleCommand::class,
Route::class,
], true)) {
$purposeAttributeCount++;
}
}
return $purposeAttributeCount > 1;
}
/**
* Get other purpose attributes on the same method
*
* @return array<string>
*/
private function getOtherPurposeAttributes(WrappedReflectionMethod $method): array
{
$attributes = $method->getAttributes();
$otherAttributes = [];
foreach ($attributes as $attribute) {
$attributeName = $attribute->getName();
if (in_array($attributeName, [
ConsoleCommand::class,
Route::class,
], true)) {
$otherAttributes[] = $attributeName;
}
}
return $otherAttributes;
}
private function generateInputSchema(WrappedReflectionMethod $method, McpTool $tool): array private function generateInputSchema(WrappedReflectionMethod $method, McpTool $tool): array
{ {
$schema = [ $schema = [
@@ -262,7 +339,7 @@ final readonly class McpToolMapper implements AttributeMapper
$type = $param->getType(); $type = $param->getType();
$parameters[] = [ $parameters[] = [
'name' => $param->getName(), 'name' => $param->getName(),
'type' => $type ? $type->getName() : 'mixed', 'type' => $type ? ($type instanceof \ReflectionNamedType ? $type->getName() : 'mixed') : 'mixed',
'required' => ! $param->isOptional(), 'required' => ! $param->isOptional(),
'default' => $param->isOptional() ? $param->getDefaultValue() : null, 'default' => $param->isOptional() ? $param->getDefaultValue() : null,
]; ];

View File

@@ -4,26 +4,29 @@ declare(strict_types=1);
namespace App\Framework\Mcp\Tools; namespace App\Framework\Mcp\Tools;
use App\Framework\HttpClient\HttpClient; use App\Framework\Console\ConsoleCommand;
use App\Framework\HttpClient\HttpMethod;
use App\Framework\Mcp\McpTool; use App\Framework\Mcp\McpTool;
use App\Framework\Router\GenericResult;
use App\Infrastructure\Api\Gitea\GiteaClient;
/** /**
* Gitea Repository Management MCP Tools * Gitea Repository Management MCP Tools
* *
* Provides AI-accessible Gitea API operations for repository management, * Provides AI-accessible Gitea API operations for repository management,
* SSH key setup, and deployment automation. * issue tracking, CI/CD workflows, and deployment automation.
*
* Architecture: Leverages Infrastructure/Api/Gitea service classes for
* clean separation and better maintainability.
*/ */
final readonly class GiteaTools final readonly class GiteaTools
{ {
public function __construct( public function __construct(
private HttpClient $httpClient, private GiteaClient $giteaClient
private string $giteaUrl,
private string $giteaUsername,
private string $giteaPassword
) { ) {
} }
// ==================== Repository Management ====================
#[McpTool( #[McpTool(
name: 'gitea_create_repository', name: 'gitea_create_repository',
description: 'Create a new repository in Gitea' description: 'Create a new repository in Gitea'
@@ -35,67 +38,51 @@ final readonly class GiteaTools
bool $autoInit = false, bool $autoInit = false,
string $defaultBranch = 'main' string $defaultBranch = 'main'
): array { ): array {
$url = "{$this->giteaUrl}/api/v1/user/repos"; try {
$data = [
'name' => $name,
'description' => $description,
'private' => $private,
'auto_init' => $autoInit,
'default_branch' => $defaultBranch,
];
$data = [ $repository = $this->giteaClient->repositories->create($data);
'name' => $name,
'description' => $description,
'private' => $private,
'auto_init' => $autoInit,
'default_branch' => $defaultBranch,
];
$result = $this->makeRequest(HttpMethod::POST, $url, $data);
if ($result['success']) {
return [ return [
'success' => true, 'success' => true,
'repository' => [ 'repository' => $this->formatRepository($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,
],
]; ];
} catch (\Exception $e) {
return $this->formatError($e, 'Failed to create repository');
} }
return $result;
} }
#[McpTool( #[McpTool(
name: 'gitea_list_repositories', name: 'gitea_list_repositories',
description: 'List all repositories for the authenticated user' description: 'List all repositories for the authenticated user'
)] )]
public function listRepositories(): array public function listRepositories(int $page = 1, int $limit = 30): array
{ {
$url = "{$this->giteaUrl}/api/v1/user/repos"; try {
$repositories = $this->giteaClient->repositories->list([
$result = $this->makeRequest(HttpMethod::GET, $url); 'page' => $page,
'limit' => $limit,
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 [ return [
'success' => true, 'success' => true,
'repositories' => $repos, 'repositories' => array_map(
'count' => count($repos), fn($repo) => $this->formatRepository($repo),
$repositories
),
'count' => count($repositories),
'page' => $page,
'limit' => $limit,
]; ];
} catch (\Exception $e) {
return $this->formatError($e, 'Failed to list repositories');
} }
return $result;
} }
#[McpTool( #[McpTool(
@@ -104,198 +91,374 @@ final readonly class GiteaTools
)] )]
public function getRepository(string $owner, string $repo): array public function getRepository(string $owner, string $repo): array
{ {
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo"; try {
$repository = $this->giteaClient->repositories->get($owner, $repo);
$result = $this->makeRequest(HttpMethod::GET, $url);
if ($result['success']) {
$repo = $result['response'];
return [ return [
'success' => true, 'success' => true,
'repository' => [ 'repository' => $this->formatRepository($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,
],
]; ];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to get repository {$owner}/{$repo}");
} }
}
return $result; #[McpTool(
name: 'gitea_update_repository',
description: 'Update repository settings'
)]
public function updateRepository(
string $owner,
string $repo,
?string $description = null,
?bool $private = null,
?string $website = null,
?bool $hasIssues = null,
?bool $hasWiki = null,
?string $defaultBranch = null
): array {
try {
$data = array_filter([
'description' => $description,
'private' => $private,
'website' => $website,
'has_issues' => $hasIssues,
'has_wiki' => $hasWiki,
'default_branch' => $defaultBranch,
], fn($value) => $value !== null);
$repository = $this->giteaClient->repositories->update($owner, $repo, $data);
return [
'success' => true,
'repository' => $this->formatRepository($repository),
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to update repository {$owner}/{$repo}");
}
} }
#[McpTool( #[McpTool(
name: 'gitea_delete_repository', name: 'gitea_delete_repository',
description: 'Delete a repository' description: 'Delete a repository (DANGEROUS - permanent deletion)'
)] )]
public function deleteRepository(string $owner, string $repo): array public function deleteRepository(string $owner, string $repo): array
{ {
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo"; try {
$this->giteaClient->repositories->delete($owner, $repo);
return $this->makeRequest(HttpMethod::DELETE, $url); return [
'success' => true,
'message' => "Repository {$owner}/{$repo} deleted successfully",
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to delete repository {$owner}/{$repo}");
}
} }
// ==================== Issue Management ====================
#[McpTool( #[McpTool(
name: 'gitea_add_deploy_key', name: 'gitea_create_issue',
description: 'Add an SSH deploy key to a repository' description: 'Create a new issue in a repository'
)] )]
public function addDeployKey( public function createIssue(
string $owner, string $owner,
string $repo, string $repo,
string $title, string $title,
string $key, string $body = '',
bool $readOnly = true ?array $labels = null,
?array $assignees = null,
?int $milestone = null
): array { ): array {
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys"; try {
$data = array_filter([
'title' => $title,
'body' => $body,
'labels' => $labels,
'assignees' => $assignees,
'milestone' => $milestone,
], fn($value) => $value !== null);
$data = [ $issue = $this->giteaClient->issues->create($owner, $repo, $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 [ return [
'success' => true, 'success' => true,
'deploy_keys' => $keys, 'issue' => $this->formatIssue($issue),
'count' => count($keys),
]; ];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to create issue in {$owner}/{$repo}");
} }
return $result;
} }
#[McpTool( #[McpTool(
name: 'gitea_delete_deploy_key', name: 'gitea_list_issues',
description: 'Delete a deploy key from a repository' description: 'List issues in a repository'
)] )]
public function deleteDeployKey(string $owner, string $repo, int $keyId): array public function listIssues(
{ string $owner,
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys/$keyId"; string $repo,
string $state = 'open',
?array $labels = null,
int $page = 1,
int $limit = 30
): array {
try {
$options = array_filter([
'state' => $state,
'labels' => $labels ? implode(',', $labels) : null,
'page' => $page,
'limit' => $limit,
], fn($value) => $value !== null);
return $this->makeRequest(HttpMethod::DELETE, $url); $issues = $this->giteaClient->issues->list($owner, $repo, $options);
}
#[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 [ return [
'success' => true, 'success' => true,
'ssh_keys' => $keys, 'issues' => array_map(
'count' => count($keys), fn($issue) => $this->formatIssue($issue),
$issues
),
'count' => count($issues),
'state' => $state,
'page' => $page,
'limit' => $limit,
]; ];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to list issues in {$owner}/{$repo}");
} }
return $result;
} }
#[McpTool( #[McpTool(
name: 'gitea_delete_user_ssh_key', name: 'gitea_get_issue',
description: 'Delete an SSH key from the authenticated user' description: 'Get details of a specific issue'
)] )]
public function deleteUserSshKey(int $keyId): array public function getIssue(string $owner, string $repo, int $index): array
{ {
$url = "{$this->giteaUrl}/api/v1/user/keys/$keyId"; try {
$issue = $this->giteaClient->issues->get($owner, $repo, $index);
return $this->makeRequest(HttpMethod::DELETE, $url); return [
'success' => true,
'issue' => $this->formatIssue($issue),
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to get issue #{$index} in {$owner}/{$repo}");
}
} }
#[McpTool(
name: 'gitea_update_issue',
description: 'Update an existing issue'
)]
public function updateIssue(
string $owner,
string $repo,
int $index,
?string $title = null,
?string $body = null,
?string $state = null,
?array $labels = null,
?array $assignees = null
): array {
try {
$data = array_filter([
'title' => $title,
'body' => $body,
'state' => $state,
'labels' => $labels,
'assignees' => $assignees,
], fn($value) => $value !== null);
$issue = $this->giteaClient->issues->update($owner, $repo, $index, $data);
return [
'success' => true,
'issue' => $this->formatIssue($issue),
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to update issue #{$index} in {$owner}/{$repo}");
}
}
#[McpTool(
name: 'gitea_close_issue',
description: 'Close an issue'
)]
public function closeIssue(string $owner, string $repo, int $index): array
{
try {
$issue = $this->giteaClient->issues->close($owner, $repo, $index);
return [
'success' => true,
'issue' => $this->formatIssue($issue),
'message' => "Issue #{$index} closed successfully",
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to close issue #{$index} in {$owner}/{$repo}");
}
}
// ==================== CI/CD & Actions ====================
#[McpTool(
name: 'gitea_list_workflows',
description: 'List all workflows in a repository'
)]
#[ConsoleCommand(
name: 'gitea:workflows:list',
description: 'List all workflows in a Gitea repository'
)]
public function listWorkflows(string $owner, string $repo): GenericResult
{
try {
$workflows = $this->giteaClient->actions->listWorkflows($owner, $repo);
return GenericResult::success([
'workflows' => $workflows['workflows'] ?? [],
'count' => count($workflows['workflows'] ?? []),
]);
} catch (\Exception $e) {
return GenericResult::fromException($e);
}
}
#[McpTool(
name: 'gitea_trigger_workflow',
description: 'Manually trigger a workflow'
)]
public function triggerWorkflow(
string $owner,
string $repo,
string $workflowId,
?string $ref = null,
?array $inputs = null
): array {
try {
$this->giteaClient->actions->triggerWorkflow(
$owner,
$repo,
$workflowId,
$inputs ?? [],
$ref
);
return [
'success' => true,
'message' => "Workflow {$workflowId} triggered successfully",
'workflow_id' => $workflowId,
'ref' => $ref,
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to trigger workflow {$workflowId}");
}
}
#[McpTool(
name: 'gitea_list_workflow_runs',
description: 'List workflow runs for a repository'
)]
#[ConsoleCommand(
name: 'gitea:workflows:runs',
description: 'List workflow runs for a Gitea repository'
)]
public function listWorkflowRuns(
string $owner,
string $repo,
?string $status = null,
?int $workflowId = null,
int $page = 1,
int $limit = 30
): GenericResult {
try {
$options = array_filter([
'status' => $status,
'workflow_id' => $workflowId,
'page' => $page,
'limit' => $limit,
], fn($value) => $value !== null);
$runs = $this->giteaClient->actions->listRuns($owner, $repo, $options);
return GenericResult::success([
'runs' => array_map(
fn($run) => $this->formatWorkflowRun($run),
$runs['workflow_runs'] ?? []
),
'count' => count($runs['workflow_runs'] ?? []),
'page' => $page,
'limit' => $limit,
]);
} catch (\Exception $e) {
return GenericResult::fromException($e);
}
}
#[McpTool(
name: 'gitea_get_workflow_run',
description: 'Get details of a specific workflow run'
)]
#[ConsoleCommand(
name: 'gitea:workflows:run',
description: 'Get details of a specific Gitea workflow run'
)]
public function getWorkflowRun(string $owner, string $repo, int $runId): GenericResult
{
try {
$run = $this->giteaClient->actions->getRun($owner, $repo, $runId);
return GenericResult::success([
'run' => $this->formatWorkflowRun($run),
]);
} catch (\Exception $e) {
return GenericResult::fromException($e, [
'context' => "Failed to get workflow run #{$runId}",
]);
}
}
#[McpTool(
name: 'gitea_cancel_workflow_run',
description: 'Cancel a running workflow'
)]
public function cancelWorkflowRun(string $owner, string $repo, int $runId): array
{
try {
$this->giteaClient->actions->cancelRun($owner, $repo, $runId);
return [
'success' => true,
'message' => "Workflow run #{$runId} cancelled successfully",
'run_id' => $runId,
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to cancel workflow run #{$runId}");
}
}
#[McpTool(
name: 'gitea_get_workflow_logs',
description: 'Get logs of a workflow run'
)]
public function getWorkflowLogs(string $owner, string $repo, int $runId): array
{
try {
$logs = $this->giteaClient->actions->getLogs($owner, $repo, $runId);
return [
'success' => true,
'logs' => $logs,
'run_id' => $runId,
'size' => strlen($logs),
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to get workflow logs for run #{$runId}");
}
}
// ==================== Git Remote Integration ====================
#[McpTool( #[McpTool(
name: 'gitea_add_remote', name: 'gitea_add_remote',
description: 'Add Gitea repository as git remote' description: 'Add Gitea repository as git remote'
@@ -306,150 +469,130 @@ final readonly class GiteaTools
string $repo, string $repo,
bool $useSsh = true bool $useSsh = true
): array { ): array {
// Get repository info first try {
$repoInfo = $this->getRepository($owner, $repo); // Get repository info first
$repository = $this->giteaClient->repositories->get($owner, $repo);
if (! $repoInfo['success']) { $url = $useSsh
return $repoInfo; ? $repository['ssh_url']
} : $repository['clone_url'];
$url = $useSsh if (!$url) {
? $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 [ return [
'success' => false, 'success' => false,
'error' => 'Remote already exists', 'error' => 'Repository URL not found',
'suggestion' => "Use 'git remote set-url $remoteName $url' to update", ];
}
// 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' => 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 [ return [
'success' => true, 'success' => true,
'webhook' => [ 'remote_name' => $remoteName,
'id' => $result['response']['id'] ?? null, 'url' => $url,
'url' => $result['response']['config']['url'] ?? $url, 'use_ssh' => $useSsh,
'events' => $result['response']['events'] ?? $events,
'active' => $result['response']['active'] ?? $active,
'created_at' => $result['response']['created_at'] ?? null,
],
]; ];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to add remote {$remoteName}");
} }
return $result;
} }
// ==================== Private Helper Methods ==================== // ==================== Private Helper Methods ====================
private function makeRequest(HttpMethod $method, string $url, ?array $data = null): array private function formatRepository(array $repo): array
{ {
try { return [
$options = [ 'id' => $repo['id'] ?? null,
'headers' => [ 'name' => $repo['name'] ?? 'unknown',
'Content-Type' => 'application/json', 'full_name' => $repo['full_name'] ?? 'unknown',
'Accept' => 'application/json', 'description' => $repo['description'] ?? '',
'Authorization' => 'Basic ' . base64_encode("{$this->giteaUsername}:{$this->giteaPassword}"), 'private' => $repo['private'] ?? false,
], 'clone_url' => $repo['clone_url'] ?? null,
'verify_ssl' => false, // For self-signed certificates '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,
'stars_count' => $repo['stars_count'] ?? 0,
'forks_count' => $repo['forks_count'] ?? 0,
'open_issues_count' => $repo['open_issues_count'] ?? 0,
];
}
if ($data !== null) { private function formatIssue(array $issue): array
$options['json'] = $data; {
} return [
'id' => $issue['id'] ?? null,
'number' => $issue['number'] ?? null,
'title' => $issue['title'] ?? 'Untitled',
'body' => $issue['body'] ?? '',
'state' => $issue['state'] ?? 'open',
'labels' => array_map(
fn($label) => $label['name'] ?? 'unknown',
$issue['labels'] ?? []
),
'assignees' => array_map(
fn($assignee) => $assignee['username'] ?? 'unknown',
$issue['assignees'] ?? []
),
'html_url' => $issue['html_url'] ?? null,
'created_at' => $issue['created_at'] ?? null,
'updated_at' => $issue['updated_at'] ?? null,
'closed_at' => $issue['closed_at'] ?? null,
];
}
$response = $this->httpClient->request($method, $url, $options); private function formatWorkflowRun(array $run): array
{
return [
'id' => $run['id'] ?? null,
'name' => $run['name'] ?? 'Unnamed Workflow',
'status' => $run['status'] ?? 'unknown',
'conclusion' => $run['conclusion'] ?? null,
'workflow_id' => $run['workflow_id'] ?? null,
'event' => $run['event'] ?? null,
'head_branch' => $run['head_branch'] ?? null,
'head_sha' => $run['head_sha'] ?? null,
'html_url' => $run['html_url'] ?? null,
'created_at' => $run['created_at'] ?? null,
'updated_at' => $run['updated_at'] ?? null,
'run_started_at' => $run['run_started_at'] ?? null,
];
}
$statusCode = $response->getStatusCode(); private function formatError(\Exception $e, string $context): array
$body = $response->getBody(); {
return [
// Decode JSON response 'success' => false,
$decoded = json_decode($body, true); 'error' => $context,
'message' => $e->getMessage(),
if ($statusCode >= 200 && $statusCode < 300) { 'exception' => get_class($e),
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

@@ -4,34 +4,25 @@ declare(strict_types=1);
namespace App\Framework\Mcp\Tools; namespace App\Framework\Mcp\Tools;
use App\Framework\Config\Environment;
use App\Framework\DI\Initializer; use App\Framework\DI\Initializer;
use App\Framework\HttpClient\HttpClient; use App\Infrastructure\Api\Gitea\GiteaClient;
/** /**
* Initializer for Gitea MCP Tools * Initializer for Gitea MCP Tools
*
* Registers GiteaTools with the DI container using the GiteaClient service.
* The GiteaClient is initialized separately with proper configuration via GiteaClientInitializer.
*/ */
final readonly class GiteaToolsInitializer final readonly class GiteaToolsInitializer
{ {
public function __construct( public function __construct(
private HttpClient $httpClient, private GiteaClient $giteaClient
private Environment $environment
) { ) {
} }
#[Initializer] #[Initializer]
public function __invoke(): GiteaTools public function __invoke(): GiteaTools
{ {
// Get Gitea configuration from environment return new GiteaTools($this->giteaClient);
$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

@@ -92,8 +92,9 @@ final readonly class JobChainCommands
foreach ($status['job_statuses'] as $jobStatus) { foreach ($status['job_statuses'] as $jobStatus) {
$canExecute = $jobStatus['can_execute'] ? '✅' : '⏳'; $canExecute = $jobStatus['can_execute'] ? '✅' : '⏳';
$depStatus = "{$jobStatus['dependencies_satisfied']}/{$jobStatus['dependencies_total']} deps"; $depStatus = "{$jobStatus['dependencies_satisfied']}/{$jobStatus['dependencies_total']} deps";
$position = $jobStatus['position'] + 1;
echo " {$canExecute} Job {$jobStatus['position'] + 1}: {$jobStatus['job_id']} ({$depStatus})\n"; echo " {$canExecute} Job {$position}: {$jobStatus['job_id']} ({$depStatus})\n";
} }
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -167,7 +168,8 @@ final readonly class JobChainCommands
echo "🔗 {$statusIcon} {$chain['name']} ({$chain['chain_id']})\n"; echo "🔗 {$statusIcon} {$chain['name']} ({$chain['chain_id']})\n";
echo " Status: {$chain['status']}\n"; echo " Status: {$chain['status']}\n";
echo " Mode: {$chain['execution_mode']}\n"; echo " Mode: {$chain['execution_mode']}\n";
echo " Position: {$chain['job_position'] + 1}/{$chain['total_jobs']}\n"; $position = $chain['job_position'] + 1;
echo " Position: {$position}/{$chain['total_jobs']}\n";
if ($chain['next_job_after_current']) { if ($chain['next_job_after_current']) {
echo " Next job: {$chain['next_job_after_current']}\n"; echo " Next job: {$chain['next_job_after_current']}\n";

View File

@@ -192,11 +192,12 @@ final readonly class JobDependencyCommands
foreach ($health['issues'] as $issue) { foreach ($health['issues'] as $issue) {
echo " - {$issue['type']}: "; echo " - {$issue['type']}: ";
match($issue['type']) { $message = match($issue['type']) {
'stalled_chain' => echo "Chain {$issue['chain_id']} running for {$issue['hours_running']} hours\n", 'stalled_chain' => "Chain {$issue['chain_id']} running for {$issue['hours_running']} hours\n",
'many_unsatisfied_dependencies' => echo "Job {$issue['job_id']} has {$issue['unsatisfied_count']} unsatisfied dependencies\n", 'many_unsatisfied_dependencies' => "Job {$issue['job_id']} has {$issue['unsatisfied_count']} unsatisfied dependencies\n",
default => echo "Unknown issue\n" default => "Unknown issue\n"
}; };
echo $message;
} }
} }
} }

View File

@@ -99,8 +99,22 @@ final class MethodCache implements \App\Framework\Reflection\Contracts\Reflectio
if (! isset($this->methodCache[$key])) { if (! isset($this->methodCache[$key])) {
/** @var class-string $classNameString */ /** @var class-string $classNameString */
$classNameString = $className->getFullyQualified(); $classNameString = $className->getFullyQualified();
$class = new \ReflectionClass($classNameString);
$this->methodCache[$key] = $class->getMethods($filter); try {
$class = new \ReflectionClass($classNameString);
$this->methodCache[$key] = $class->getMethods($filter);
} catch (\ReflectionException $e) {
// Provide context about which class failed to reflect
$errorMessage = sprintf(
'Failed to reflect class "%s" in MethodCache::getNativeMethods(): %s',
$classNameString,
$e->getMessage()
);
error_log($errorMessage);
// Re-throw with more context
throw new \ReflectionException($errorMessage, $e->getCode(), $e);
}
} }
return $this->methodCache[$key]; return $this->methodCache[$key];

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Result\ConsoleResult;
use App\Framework\Console\Result\TextResult;
use App\Framework\Mcp\Core\ValueObjects\ToolResult;
/**
* Generic Action Result
*
* Universal result object that can be converted to both ToolResult (MCP)
* and ConsoleResult (console commands). This enables methods to have dual
* attributes (#[McpTool] and #[ConsoleCommand]) with a single return type.
*
* Architecture:
* - Methods return GenericResult
* - MCP context: Automatically converts to ToolResult
* - Console context: Automatically converts to ConsoleResult (TextResult)
*
* Example:
* #[McpTool(name: 'list_users')]
* #[ConsoleCommand(name: 'users:list')]
* public function listUsers(): GenericResult
* {
* return GenericResult::success(['users' => $users]);
* }
*/
final readonly class GenericResult implements ActionResult, ConsoleResult
{
public readonly array $data;
public readonly ExitCode $exitCode;
/**
* Create generic result
*
* Use factory methods (success/failure/error) for convenience.
*/
public function __construct(
public mixed $payload,
public bool $success,
public ?string $error = null,
public array $metadata = [],
?ExitCode $exitCode = null
) {
$this->exitCode = $exitCode ?? ($success ? ExitCode::SUCCESS : ExitCode::FAILURE);
$this->data = [
'success' => $this->success,
'payload' => $this->payload,
'error' => $this->error,
'metadata' => $this->metadata,
'exit_code' => $this->exitCode->value,
];
}
/**
* Create success result
*
* @param mixed $payload Result data
* @param array $metadata Additional metadata
*/
public static function success(mixed $payload, array $metadata = []): self
{
return new self(
payload: $payload,
success: true,
metadata: $metadata
);
}
/**
* Create failure result
*
* @param string $error Error message
* @param mixed $payload Optional partial data
* @param array $metadata Additional metadata
*/
public static function failure(string $error, mixed $payload = null, array $metadata = []): self
{
return new self(
payload: $payload,
success: false,
error: $error,
metadata: $metadata
);
}
/**
* Create failure from exception
*
* @param \Throwable $exception Exception to convert
* @param array $metadata Additional metadata
*/
public static function fromException(\Throwable $exception, array $metadata = []): self
{
return new self(
payload: null,
success: false,
error: $exception->getMessage(),
metadata: array_merge($metadata, [
'exception_type' => get_class($exception),
'exception_file' => $exception->getFile(),
'exception_line' => $exception->getLine(),
])
);
}
/**
* Convert to ToolResult for MCP context
*
* Automatic conversion when accessed via MCP tools.
*/
public function toToolResult(): ToolResult
{
if ($this->success) {
return ToolResult::success(
data: $this->payload,
metadata: $this->metadata
);
}
return ToolResult::failure(
error: $this->error ?? 'Unknown error',
data: $this->payload,
metadata: $this->metadata
);
}
/**
* Convert to ConsoleResult for console context
*
* Automatic conversion when accessed via console commands.
*/
public function toConsoleResult(): ConsoleResult
{
if ($this->success) {
$message = $this->formatSuccessMessage();
return TextResult::success($message);
}
return TextResult::error($this->error ?? 'Unknown error');
}
/**
* Render to console output (ConsoleResult interface)
*
* Delegates to TextResult for rendering.
*/
public function render(ConsoleOutputInterface $output): void
{
$this->toConsoleResult()->render($output);
}
/**
* Format success message for console output
*/
private function formatSuccessMessage(): string
{
if (is_string($this->payload)) {
return $this->payload;
}
if (is_array($this->payload)) {
// Check for common success message keys
if (isset($this->payload['message'])) {
return (string) $this->payload['message'];
}
// Format array data as JSON for readability
return json_encode($this->payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
// Fallback: Generic success message
return 'Operation completed successfully';
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Exception thrown when a bucket is not found
*/
final class BucketNotFoundException extends StorageException
{
public function __construct(
string $bucket,
?\Throwable $previous = null
) {
$context = ExceptionContext::forOperation('storage.bucket', 'ObjectStorage')
->withData(['bucket' => $bucket]);
parent::__construct(
message: "Bucket not found: {$bucket}",
context: $context,
errorCode: ErrorCode::NOT_FOUND,
previous: $previous,
code: 404
);
}
public static function for(string $bucket, ?\Throwable $previous = null): self
{
return new self($bucket, $previous);
}
public function getBucket(): string
{
return $this->getContext()->getData()['bucket'] ?? '';
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Exception thrown when an object is not found in storage
*/
final class ObjectNotFoundException extends StorageException
{
public function __construct(
string $bucket,
string $key,
?\Throwable $previous = null
) {
$context = ExceptionContext::forOperation('storage.get', 'ObjectStorage')
->withData([
'bucket' => $bucket,
'key' => $key,
]);
parent::__construct(
message: "Object not found: {$bucket}/{$key}",
context: $context,
errorCode: ErrorCode::NOT_FOUND,
previous: $previous,
code: 404
);
}
public static function for(string $bucket, string $key, ?\Throwable $previous = null): self
{
return new self($bucket, $key, $previous);
}
public function getBucket(): string
{
return $this->getContext()->getData()['bucket'] ?? '';
}
public function getKey(): string
{
return $this->getContext()->getData()['key'] ?? '';
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Exception thrown when storage connection fails
*/
final class StorageConnectionException extends StorageException
{
public function __construct(
string $endpoint,
string $reason = '',
?\Throwable $previous = null
) {
$context = ExceptionContext::forOperation('storage.connect', 'Storage')
->withData([
'endpoint' => $endpoint,
'reason' => $reason,
]);
$message = "Failed to connect to storage endpoint: {$endpoint}";
if ($reason !== '') {
$message .= " ({$reason})";
}
parent::__construct(
message: $message,
context: $context,
errorCode: ErrorCode::CONNECTION_FAILED,
previous: $previous,
code: 503
);
}
public static function for(string $endpoint, string $reason = '', ?\Throwable $previous = null): self
{
return new self($endpoint, $reason, $previous);
}
public function getEndpoint(): string
{
return $this->getContext()->getData()['endpoint'] ?? '';
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\ExceptionMetadata;
use App\Framework\Exception\FrameworkException;
/**
* Base exception for Storage operations
*
* All storage-related exceptions should extend this class.
*/
class StorageException extends FrameworkException
{
public function __construct(
string $message,
ExceptionContext $context,
?ErrorCode $errorCode = null,
?\Throwable $previous = null,
int $code = 0,
?ExceptionMetadata $metadata = null
) {
parent::__construct($message, $context, $code, $previous, $errorCode, $metadata);
}
/**
* Create exception for bucket/key not found
*/
public static function notFound(string $bucket, string $key, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('storage.get', 'Storage')
->withData([
'bucket' => $bucket,
'key' => $key,
]);
return new self(
message: "Object not found: {$bucket}/{$key}",
context: $context,
errorCode: ErrorCode::NOT_FOUND,
previous: $previous,
code: 404
);
}
/**
* Create exception for connection errors
*/
public static function connectionFailed(string $endpoint, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('storage.connect', 'Storage')
->withData(['endpoint' => $endpoint]);
return new self(
message: "Failed to connect to storage endpoint: {$endpoint}",
context: $context,
errorCode: ErrorCode::CONNECTION_FAILED,
previous: $previous,
code: 503
);
}
/**
* Create exception for operation failures
*/
public static function operationFailed(string $operation, string $bucket, string $key, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation("storage.{$operation}", 'Storage')
->withData([
'bucket' => $bucket,
'key' => $key,
]);
return new self(
message: "Storage operation failed: {$operation} on {$bucket}/{$key}",
context: $context,
errorCode: ErrorCode::OPERATION_FAILED,
previous: $previous,
code: 500
);
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Exception thrown when a storage operation fails
*/
final class StorageOperationException extends StorageException
{
public function __construct(
string $operation,
string $bucket,
string $key,
string $reason = '',
?\Throwable $previous = null
) {
$context = ExceptionContext::forOperation("storage.{$operation}", 'Storage')
->withData([
'bucket' => $bucket,
'key' => $key,
'reason' => $reason,
]);
$message = "Storage operation '{$operation}' failed for {$bucket}/{$key}";
if ($reason !== '') {
$message .= ": {$reason}";
}
parent::__construct(
message: $message,
context: $context,
errorCode: ErrorCode::OPERATION_FAILED,
previous: $previous,
code: 500
);
}
public static function for(string $operation, string $bucket, string $key, string $reason = '', ?\Throwable $previous = null): self
{
return new self($operation, $bucket, $key, $reason, $previous);
}
public function getOperation(): string
{
return $this->getContext()->getOperation();
}
public function getBucket(): string
{
return $this->getContext()->getData()['bucket'] ?? '';
}
public function getKey(): string
{
return $this->getContext()->getData()['key'] ?? '';
}
}

View File

@@ -0,0 +1,360 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Filesystem\Storage;
use App\Framework\Http\CustomMimeType;
use App\Framework\Http\MimeType;
use App\Framework\Storage\Exceptions\ObjectNotFoundException;
use App\Framework\Storage\Exceptions\StorageOperationException;
use App\Framework\Storage\ValueObjects\BucketName;
use App\Framework\Storage\ValueObjects\ObjectKey;
use App\Framework\Storage\ValueObjects\ObjectMetadata;
use DateInterval;
/**
* Filesystem-based Object Storage implementation
*
* Maps S3-style buckets/keys to filesystem directories/files:
* - Buckets = directories
* - Keys = files within bucket directories
*/
final readonly class FilesystemObjectStorage implements ObjectStorage, StreamableObjectStorage
{
private string $basePath;
public function __construct(
private Storage $storage,
string $basePath = '/'
) {
// Normalize base path
$this->basePath = rtrim($basePath, '/');
}
public function put(string $bucket, string $key, string $body, array $opts = []): ObjectInfo
{
$bucketName = BucketName::fromString($bucket);
$objectKey = ObjectKey::fromString($key);
$path = $this->buildPath($bucket, $key);
try {
// Ensure bucket directory exists
$bucketPath = $this->buildBucketPath($bucket);
if (! $this->storage->exists($bucketPath)) {
$this->storage->createDirectory($bucketPath);
}
// Store content
$this->storage->put($path, $body);
// Get file metadata
$size = FileSize::fromBytes($this->storage->size($path));
$lastModified = Timestamp::fromTimestamp($this->storage->lastModified($path));
$mimeTypeString = $this->storage->getMimeType($path);
// Generate ETag (SHA256 hash of content)
$etag = Hash::sha256($body);
// Convert MIME type string to Value Object
$contentType = MimeType::tryFrom($mimeTypeString) ?? CustomMimeType::fromString($mimeTypeString);
$metadata = ObjectMetadata::fromArray($opts['metadata'] ?? []);
return new ObjectInfo(
bucket: $bucketName,
key: $objectKey,
etag: $etag,
size: $size,
contentType: $contentType,
lastModified: $lastModified,
metadata: $metadata,
versionId: null
);
} catch (\Throwable $e) {
throw StorageOperationException::for('put', $bucket, $key, $e->getMessage(), $e);
}
}
public function get(string $bucket, string $key): string
{
$path = $this->buildPath($bucket, $key);
try {
if (! $this->storage->exists($path)) {
throw ObjectNotFoundException::for($bucket, $key);
}
return $this->storage->get($path);
} catch (ObjectNotFoundException $e) {
throw $e;
} catch (\Throwable $e) {
throw StorageOperationException::for('get', $bucket, $key, $e->getMessage(), $e);
}
}
public function stream(string $bucket, string $key)
{
// Backward compatibility: returns temporary stream
return $this->openReadStream($bucket, $key);
}
public function getToStream(string $bucket, string $key, $destination, array $opts = []): int
{
$path = $this->buildPath($bucket, $key);
try {
if (! $this->storage->exists($path)) {
throw ObjectNotFoundException::for($bucket, $key);
}
// Validate destination stream
if (! is_resource($destination)) {
throw StorageOperationException::for('getToStream', $bucket, $key, 'Invalid destination stream');
}
// Use Storage's readStream if available
if (method_exists($this->storage, 'readStream')) {
$sourceStream = $this->storage->readStream($path);
$bufferSize = $opts['bufferSize'] ?? 8192;
try {
$bytesWritten = stream_copy_to_stream($sourceStream, $destination, null, $bufferSize);
if ($bytesWritten === false) {
throw StorageOperationException::for('getToStream', $bucket, $key, 'Failed to copy stream');
}
return $bytesWritten;
} finally {
fclose($sourceStream);
}
}
// Fallback: read content and write to stream
$content = $this->storage->get($path);
$bytesWritten = fwrite($destination, $content);
if ($bytesWritten === false) {
throw StorageOperationException::for('getToStream', $bucket, $key, 'Failed to write to destination stream');
}
return $bytesWritten;
} catch (ObjectNotFoundException $e) {
throw $e;
} catch (StorageOperationException $e) {
throw $e;
} catch (\Throwable $e) {
throw StorageOperationException::for('getToStream', $bucket, $key, $e->getMessage(), $e);
}
}
public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo
{
$path = $this->buildPath($bucket, $key);
try {
// Validate source stream
if (! is_resource($source)) {
throw StorageOperationException::for('putFromStream', $bucket, $key, 'Invalid source stream');
}
// Ensure bucket directory exists
$bucketPath = $this->buildBucketPath($bucket);
if (! $this->storage->exists($bucketPath)) {
$this->storage->createDirectory($bucketPath);
}
// Use Storage's putStream if available
if (method_exists($this->storage, 'putStream')) {
$this->storage->putStream($path, $source);
} else {
// Fallback: read stream content and use put()
$content = stream_get_contents($source);
if ($content === false) {
throw StorageOperationException::for('putFromStream', $bucket, $key, 'Failed to read from source stream');
}
$this->storage->put($path, $content);
}
// Reuse head() logic to get ObjectInfo
return $this->head($bucket, $key);
} catch (StorageOperationException $e) {
throw $e;
} catch (\Throwable $e) {
throw StorageOperationException::for('putFromStream', $bucket, $key, $e->getMessage(), $e);
}
}
public function openReadStream(string $bucket, string $key)
{
$path = $this->buildPath($bucket, $key);
try {
if (! $this->storage->exists($path)) {
throw ObjectNotFoundException::for($bucket, $key);
}
// Use Storage's readStream if available
if (method_exists($this->storage, 'readStream')) {
return $this->storage->readStream($path);
}
// Fallback: create stream from file content
$content = $this->storage->get($path);
$stream = fopen('php://temp', 'r+');
if ($stream === false) {
throw StorageOperationException::for('openReadStream', $bucket, $key, 'Failed to create stream');
}
fwrite($stream, $content);
rewind($stream);
return $stream;
} catch (ObjectNotFoundException $e) {
throw $e;
} catch (\Throwable $e) {
throw StorageOperationException::for('openReadStream', $bucket, $key, $e->getMessage(), $e);
}
}
public function head(string $bucket, string $key): ObjectInfo
{
$bucketName = BucketName::fromString($bucket);
$objectKey = ObjectKey::fromString($key);
$path = $this->buildPath($bucket, $key);
try {
if (! $this->storage->exists($path)) {
throw ObjectNotFoundException::for($bucket, $key);
}
$size = FileSize::fromBytes($this->storage->size($path));
$lastModified = Timestamp::fromTimestamp($this->storage->lastModified($path));
$mimeTypeString = $this->storage->getMimeType($path);
// Read content to generate ETag (could be optimized to read only if needed)
$content = $this->storage->get($path);
$etag = Hash::sha256($content);
// Convert MIME type string to Value Object
$contentType = MimeType::tryFrom($mimeTypeString) ?? CustomMimeType::fromString($mimeTypeString);
return new ObjectInfo(
bucket: $bucketName,
key: $objectKey,
etag: $etag,
size: $size,
contentType: $contentType,
lastModified: $lastModified,
metadata: ObjectMetadata::empty(),
versionId: null
);
} catch (ObjectNotFoundException $e) {
throw $e;
} catch (\Throwable $e) {
throw StorageOperationException::for('head', $bucket, $key, $e->getMessage(), $e);
}
}
public function delete(string $bucket, string $key): void
{
$path = $this->buildPath($bucket, $key);
try {
if (! $this->storage->exists($path)) {
// Object doesn't exist, but that's OK for delete operations
return;
}
$this->storage->delete($path);
} catch (\Throwable $e) {
throw StorageOperationException::for('delete', $bucket, $key, $e->getMessage(), $e);
}
}
public function exists(string $bucket, string $key): bool
{
$path = $this->buildPath($bucket, $key);
return $this->storage->exists($path);
}
public function url(string $bucket, string $key): ?string
{
// Filesystem storage doesn't have public URLs
return null;
}
public function temporaryUrl(string $bucket, string $key, DateInterval $ttl, array $opts = []): string
{
// Filesystem storage doesn't support presigned URLs
throw StorageOperationException::for(
'temporaryUrl',
$bucket,
$key,
'Temporary URLs are not supported for filesystem storage'
);
}
/**
* Build filesystem path for bucket/key
*/
private function buildPath(string $bucket, string $key): string
{
// Sanitize bucket name (prevent path traversal)
$bucket = $this->sanitizeBucketName($bucket);
$key = ltrim($key, '/');
// Sanitize key (prevent path traversal)
$key = $this->sanitizeKey($key);
return $this->basePath . '/' . $bucket . '/' . $key;
}
/**
* Build filesystem path for bucket directory
*/
private function buildBucketPath(string $bucket): string
{
$bucket = $this->sanitizeBucketName($bucket);
return $this->basePath . '/' . $bucket;
}
/**
* Sanitize bucket name to prevent path traversal
*/
private function sanitizeBucketName(string $bucket): string
{
// Remove any path separators and dangerous characters
$bucket = str_replace(['/', '\\', '..'], '', $bucket);
$bucket = trim($bucket, '.');
if ($bucket === '' || $bucket === '.') {
throw StorageOperationException::for('sanitize', $bucket, '', 'Invalid bucket name');
}
return $bucket;
}
/**
* Sanitize key to prevent path traversal
*/
private function sanitizeKey(string $key): string
{
// Remove leading slashes but preserve internal structure
$key = ltrim($key, '/');
// Prevent directory traversal
if (str_contains($key, '..')) {
throw StorageOperationException::for('sanitize', '', $key, 'Path traversal detected in key');
}
return $key;
}
}

View File

@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Http\CustomMimeType;
use App\Framework\Http\MimeType;
use App\Framework\Storage\Exceptions\ObjectNotFoundException;
use App\Framework\Storage\Exceptions\StorageOperationException;
use App\Framework\Storage\ValueObjects\BucketName;
use App\Framework\Storage\ValueObjects\ObjectKey;
use App\Framework\Storage\ValueObjects\ObjectMetadata;
use DateInterval;
/**
* In-Memory Object Storage implementation for testing
*
* Stores objects in memory without any I/O operations.
* Structure: array<string, array<string, array{content: string, metadata: array}>>
*/
final class InMemoryObjectStorage implements ObjectStorage, StreamableObjectStorage
{
/**
* Storage structure: [bucket => [key => [content, metadata, timestamp]]]
*
* @var array<string, array<string, array{content: string, metadata: array, timestamp: int}>>
*/
private array $storage = [];
public function put(string $bucket, string $key, string $body, array $opts = []): ObjectInfo
{
$bucketName = BucketName::fromString($bucket);
$objectKey = ObjectKey::fromString($key);
if (! isset($this->storage[$bucket])) {
$this->storage[$bucket] = [];
}
$etag = Hash::sha256($body);
$size = FileSize::fromBytes(strlen($body));
$timestamp = Timestamp::now();
$metadata = ObjectMetadata::fromArray($opts['metadata'] ?? []);
$contentType = null;
if (isset($opts['contentType'])) {
$contentType = MimeType::tryFrom($opts['contentType']) ?? CustomMimeType::fromString($opts['contentType']);
} else {
$contentType = MimeType::APPLICATION_OCTET_STREAM;
}
$this->storage[$bucket][$key] = [
'content' => $body,
'metadata' => $metadata->toArray(),
'timestamp' => $timestamp->toTimestamp(),
];
return new ObjectInfo(
bucket: $bucketName,
key: $objectKey,
etag: $etag,
size: $size,
contentType: $contentType,
lastModified: $timestamp,
metadata: $metadata,
versionId: null
);
}
public function get(string $bucket, string $key): string
{
if (! isset($this->storage[$bucket][$key])) {
throw ObjectNotFoundException::for($bucket, $key);
}
return $this->storage[$bucket][$key]['content'];
}
public function stream(string $bucket, string $key)
{
// Backward compatibility: returns temporary stream
return $this->openReadStream($bucket, $key);
}
public function getToStream(string $bucket, string $key, $destination, array $opts = []): int
{
if (! is_resource($destination)) {
throw StorageOperationException::for('getToStream', $bucket, $key, 'Invalid destination stream');
}
$content = $this->get($bucket, $key);
$bytesWritten = fwrite($destination, $content);
if ($bytesWritten === false) {
throw StorageOperationException::for('getToStream', $bucket, $key, 'Failed to write to destination stream');
}
return $bytesWritten;
}
public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo
{
if (! is_resource($source)) {
throw StorageOperationException::for('putFromStream', $bucket, $key, 'Invalid source stream');
}
$content = stream_get_contents($source);
if ($content === false) {
throw StorageOperationException::for('putFromStream', $bucket, $key, 'Failed to read from source stream');
}
return $this->put($bucket, $key, $content, $opts);
}
public function openReadStream(string $bucket, string $key)
{
$content = $this->get($bucket, $key);
$stream = fopen('php://temp', 'r+');
if ($stream === false) {
throw StorageOperationException::for('openReadStream', $bucket, $key, 'Failed to create stream');
}
fwrite($stream, $content);
rewind($stream);
return $stream;
}
public function head(string $bucket, string $key): ObjectInfo
{
$bucketName = BucketName::fromString($bucket);
$objectKey = ObjectKey::fromString($key);
if (! isset($this->storage[$bucket][$key])) {
throw ObjectNotFoundException::for($bucket, $key);
}
$entry = $this->storage[$bucket][$key];
$content = $entry['content'];
$etag = Hash::sha256($content);
$size = FileSize::fromBytes(strlen($content));
$lastModified = Timestamp::fromTimestamp($entry['timestamp']);
$metadata = ObjectMetadata::fromArray($entry['metadata']);
return new ObjectInfo(
bucket: $bucketName,
key: $objectKey,
etag: $etag,
size: $size,
contentType: MimeType::APPLICATION_OCTET_STREAM,
lastModified: $lastModified,
metadata: $metadata,
versionId: null
);
}
public function delete(string $bucket, string $key): void
{
if (! isset($this->storage[$bucket][$key])) {
// Object doesn't exist, but that's OK for delete operations
return;
}
unset($this->storage[$bucket][$key]);
// Clean up empty bucket
if (empty($this->storage[$bucket])) {
unset($this->storage[$bucket]);
}
}
public function exists(string $bucket, string $key): bool
{
return isset($this->storage[$bucket][$key]);
}
public function url(string $bucket, string $key): ?string
{
// In-memory storage doesn't have URLs
return null;
}
public function temporaryUrl(string $bucket, string $key, DateInterval $ttl, array $opts = []): string
{
// In-memory storage doesn't support presigned URLs
throw StorageOperationException::for(
'temporaryUrl',
$bucket,
$key,
'Temporary URLs are not supported for in-memory storage'
);
}
/**
* Clear all stored objects (for testing)
*/
public function clear(): void
{
$this->storage = [];
}
/**
* Get all buckets (for testing)
*
* @return array<string>
*/
public function listBuckets(): array
{
return array_keys($this->storage);
}
/**
* List all keys in a bucket (for testing)
*
* @return array<string>
*/
public function listKeys(string $bucket): array
{
if (! isset($this->storage[$bucket])) {
return [];
}
return array_keys($this->storage[$bucket]);
}
}

View File

@@ -1,17 +1,147 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace App\Framework\Storage; namespace App\Framework\Storage;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Http\CustomMimeType;
use App\Framework\Http\MimeType;
use App\Framework\Http\MimeTypeInterface;
use App\Framework\Storage\ValueObjects\BucketName;
use App\Framework\Storage\ValueObjects\ObjectKey;
use App\Framework\Storage\ValueObjects\ObjectMetadata;
use App\Framework\Storage\ValueObjects\VersionId;
/**
* Object Information Value Object
*
* Contains metadata about a stored object (bucket, key, size, content type, etc.)
* Uses Value Objects for type safety and validation.
*/
final readonly class ObjectInfo final readonly class ObjectInfo
{ {
public function __construct( public function __construct(
public string $bucket, public BucketName $bucket,
public string $key, public ObjectKey $key,
public ?string $etag = null, // z.B. sha256 public ?Hash $etag = null,
public ?int $size = null, public ?FileSize $size = null,
public ?string $contentType = null, public ?MimeTypeInterface $contentType = null,
public array $metadata = [], // frei (owner, width, height, …) public ?Timestamp $lastModified = null,
public ?string $versionId = null // S3: echt, FS: emuliert/optional public ObjectMetadata $metadata = new ObjectMetadata(),
) {} public ?VersionId $versionId = null
) {
}
/**
* Create ObjectInfo from legacy format (for backward compatibility)
*
* @param array<string, mixed> $legacyMetadata
*/
public static function fromLegacy(
string $bucket,
string $key,
?string $etag = null,
?int $size = null,
?string $contentType = null,
array $legacyMetadata = [],
?string $versionId = null,
?int $lastModified = null
): self {
$bucketName = BucketName::fromString($bucket);
$objectKey = ObjectKey::fromString($key);
$hash = $etag !== null ? Hash::fromString($etag, HashAlgorithm::SHA256) : null;
$fileSize = $size !== null ? FileSize::fromBytes($size) : null;
$mimeType = null;
if ($contentType !== null) {
$mimeType = MimeType::tryFrom($contentType) ?? CustomMimeType::fromString($contentType);
}
$timestamp = $lastModified !== null ? Timestamp::fromTimestamp($lastModified) : null;
$metadata = ObjectMetadata::fromArray($legacyMetadata);
$versionIdObj = $versionId !== null ? VersionId::fromString($versionId) : null;
return new self(
bucket: $bucketName,
key: $objectKey,
etag: $hash,
size: $fileSize,
contentType: $mimeType,
lastModified: $timestamp,
metadata: $metadata,
versionId: $versionIdObj
);
}
/**
* Get bucket name as string
*/
public function getBucketName(): string
{
return $this->bucket->toString();
}
/**
* Get object key as string
*/
public function getKey(): string
{
return $this->key->toString();
}
/**
* Get ETag as string (if available)
*/
public function getEtag(): ?string
{
return $this->etag?->toString();
}
/**
* Get size in bytes (if available)
*/
public function getSizeBytes(): ?int
{
return $this->size?->toBytes();
}
/**
* Get content type as string (if available)
*/
public function getContentType(): ?string
{
return $this->contentType?->getValue();
}
/**
* Get last modified timestamp (if available)
*/
public function getLastModifiedTimestamp(): ?int
{
return $this->lastModified?->toTimestamp();
}
/**
* Get metadata as array
*
* @return array<string, mixed>
*/
public function getMetadataArray(): array
{
return $this->metadata->toArray();
}
/**
* Get version ID as string (if available)
*/
public function getVersionId(): ?string
{
return $this->versionId?->toString();
}
} }

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage;
use App\Framework\Config\Environment;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Encryption\HmacService;
use App\Framework\Filesystem\Storage;
use App\Framework\HttpClient\CurlHttpClient;
use App\Framework\Random\RandomGenerator;
use App\Infrastructure\Storage\MinIoClient;
use App\Infrastructure\Storage\S3ObjectStorage;
/**
* Storage System Initializer
*
* Registriert alle Object Storage Komponenten im DI-Container
* entsprechend der Framework-Konventionen.
*/
final readonly class StorageInitializer
{
#[Initializer]
public function initializeStorage(Container $container): void
{
$env = $container->get(Environment::class);
$driver = $env->getString('STORAGE_DRIVER', 'filesystem');
// Register HmacService if not already registered
if (! $container->has(HmacService::class)) {
$container->singleton(HmacService::class, function () {
return new HmacService();
});
}
// Register MinIoClient (if needed for S3 driver)
$container->singleton(MinIoClient::class, function (Container $container) use ($env) {
return new MinIoClient(
endpoint: $env->getString('MINIO_ENDPOINT', 'http://minio:9000'),
accessKey: $env->getString('MINIO_ACCESS_KEY', 'minioadmin'),
secretKey: $env->getString('MINIO_SECRET_KEY', 'minioadmin'),
region: $env->getString('MINIO_REGION', 'us-east-1'),
usePathStyle: $env->getBool('MINIO_USE_PATH_STYLE', true),
randomGenerator: $container->get(RandomGenerator::class),
hmacService: $container->get(HmacService::class),
httpClient: $container->get(CurlHttpClient::class)
);
});
// Register S3ObjectStorage
$container->singleton(S3ObjectStorage::class, function (Container $container) {
return new S3ObjectStorage(
client: $container->get(MinIoClient::class)
);
});
// Register FilesystemObjectStorage
$container->singleton(FilesystemObjectStorage::class, function (Container $container) use ($env) {
$storage = $container->get(Storage::class);
$basePath = $env->getString('STORAGE_LOCAL_ROOT', '/var/www/html/storage/objects');
return new FilesystemObjectStorage(
storage: $storage,
basePath: $basePath
);
});
// Register default ObjectStorage based on driver
$container->singleton(ObjectStorage::class, function (Container $container) use ($driver) {
return match ($driver) {
'minio', 's3' => $container->get(S3ObjectStorage::class),
'filesystem', 'local' => $container->get(FilesystemObjectStorage::class),
'memory', 'inmemory' => $container->get(InMemoryObjectStorage::class),
default => throw new \InvalidArgumentException("Unknown storage driver: {$driver}")
};
});
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage;
/**
* Interface für Stream-basierte Object Storage Operationen
*
* Ermöglicht speichereffizientes Arbeiten mit großen Objekten ohne komplettes Laden in den Speicher.
* Ideal für große Dateien, Media-Files und Analytics-Daten.
*
* Implementiert das Interface Segregation Principle - nur Adapter die Streaming unterstützen
* implementieren dieses Interface.
*/
interface StreamableObjectStorage
{
/**
* Streamt Objekt-Inhalt direkt in einen schreibbaren Stream
*
* @param string $bucket Bucket-Name
* @param string $key Object-Key
* @param resource $destination Schreibbarer Stream (z.B. fopen('file.txt', 'w'))
* @param array<string, mixed> $opts Optionale Parameter:
* - 'bufferSize' => int (default: 8192) Buffer-Größe für Chunked Transfer
* @return int Anzahl geschriebener Bytes
* @throws \App\Framework\Storage\Exceptions\ObjectNotFoundException Wenn Objekt nicht existiert
* @throws \App\Framework\Storage\Exceptions\StorageOperationException Bei Stream-Fehlern
*/
public function getToStream(string $bucket, string $key, $destination, array $opts = []): int;
/**
* Lädt Objekt-Inhalt von einem lesbaren Stream
*
* @param string $bucket Bucket-Name
* @param string $key Object-Key
* @param resource $source Lesbarer Stream (z.B. fopen('file.txt', 'r'))
* @param array<string, mixed> $opts Optionale Parameter:
* - 'contentType' => string MIME-Type
* - 'metadata' => array<string, mixed> Metadata
* - 'bufferSize' => int (default: 8192) Buffer-Größe für Chunked Transfer
* @return ObjectInfo Metadaten des hochgeladenen Objekts
* @throws \App\Framework\Storage\Exceptions\StorageOperationException Bei Stream-Fehlern
*/
public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo;
/**
* Öffnet einen Read-Stream für das Objekt
*
* WICHTIG: Der zurückgegebene Stream muss vom Caller mit fclose() geschlossen werden!
*
* @param string $bucket Bucket-Name
* @param string $key Object-Key
* @return resource Lesbarer Stream
* @throws \App\Framework\Storage\Exceptions\ObjectNotFoundException Wenn Objekt nicht existiert
* @throws \App\Framework\Storage\Exceptions\StorageOperationException Bei Stream-Fehlern
*/
public function openReadStream(string $bucket, string $key);
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\ValueObjects;
use InvalidArgumentException;
/**
* Bucket Name Value Object
*
* Type-safe S3 bucket identifier with validation according to AWS S3 bucket naming rules.
*/
final readonly class BucketName
{
private const MIN_LENGTH = 3;
private const MAX_LENGTH = 63;
private function __construct(
private string $value
) {
$this->validate();
}
/**
* Create BucketName from string
*/
public static function fromString(string $name): self
{
return new self($name);
}
/**
* Get bucket name as string
*/
public function toString(): string
{
return $this->value;
}
/**
* String representation
*/
public function __toString(): string
{
return $this->value;
}
/**
* Check if two bucket names are equal
*/
public function equals(self $other): bool
{
return $this->value === $other->value;
}
/**
* Validate bucket name according to S3 rules
*/
private function validate(): void
{
$length = strlen($this->value);
// Length check
if ($length < self::MIN_LENGTH || $length > self::MAX_LENGTH) {
throw new InvalidArgumentException(
"Bucket name must be between " . self::MIN_LENGTH . " and " . self::MAX_LENGTH . " characters (got {$length})"
);
}
// Must start and end with alphanumeric character
if (! ctype_alnum($this->value[0])) {
throw new InvalidArgumentException('Bucket name must start with a letter or number');
}
if (! ctype_alnum($this->value[$length - 1])) {
throw new InvalidArgumentException('Bucket name must end with a letter or number');
}
// Only lowercase letters, numbers, dots, and hyphens allowed
if (! preg_match('/^[a-z0-9.-]+$/', $this->value)) {
throw new InvalidArgumentException(
'Bucket name can only contain lowercase letters, numbers, dots, and hyphens'
);
}
// No consecutive dots
if (str_contains($this->value, '..')) {
throw new InvalidArgumentException('Bucket name cannot contain consecutive dots');
}
// Cannot be formatted as an IP address (e.g., 192.168.1.1)
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $this->value)) {
throw new InvalidArgumentException('Bucket name cannot be formatted as an IP address');
}
// Cannot start with "xn--" (punycode prefix)
if (str_starts_with($this->value, 'xn--')) {
throw new InvalidArgumentException('Bucket name cannot start with "xn--"');
}
// Cannot end with "-s3alias" (S3 alias suffix)
if (str_ends_with($this->value, '-s3alias')) {
throw new InvalidArgumentException('Bucket name cannot end with "-s3alias"');
}
}
/**
* Static validation method (for pre-validation without creating object)
*/
public static function isValid(string $name): bool
{
try {
new self($name);
return true;
} catch (InvalidArgumentException) {
return false;
}
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\ValueObjects;
use InvalidArgumentException;
/**
* Object Key Value Object
*
* Type-safe S3 object key identifier with validation according to AWS S3 key naming rules.
* Supports path-like structures (e.g., "folder/subfolder/file.txt").
*/
final readonly class ObjectKey
{
private const MAX_LENGTH = 1024;
private function __construct(
private string $value
) {
$this->validate();
}
/**
* Create ObjectKey from string
*/
public static function fromString(string $key): self
{
return new self($key);
}
/**
* Get object key as string
*/
public function toString(): string
{
return $this->value;
}
/**
* String representation
*/
public function __toString(): string
{
return $this->value;
}
/**
* Check if two object keys are equal
*/
public function equals(self $other): bool
{
return $this->value === $other->value;
}
/**
* Get directory path (without filename)
*
* Example: "folder/subfolder/file.txt" -> "folder/subfolder"
*/
public function getDirectory(): ?string
{
$lastSlash = strrpos($this->value, '/');
if ($lastSlash === false) {
return null;
}
return substr($this->value, 0, $lastSlash);
}
/**
* Get filename (last path segment)
*
* Example: "folder/subfolder/file.txt" -> "file.txt"
*/
public function getFilename(): ?string
{
$lastSlash = strrpos($this->value, '/');
if ($lastSlash === false) {
return $this->value;
}
return substr($this->value, $lastSlash + 1);
}
/**
* Get file extension (without dot)
*
* Example: "file.txt" -> "txt"
*/
public function getExtension(): ?string
{
$filename = $this->getFilename();
if ($filename === null) {
return null;
}
$lastDot = strrpos($filename, '.');
if ($lastDot === false) {
return null;
}
return substr($filename, $lastDot + 1);
}
/**
* Check if key has a directory path
*/
public function hasDirectory(): bool
{
return str_contains($this->value, '/');
}
/**
* Validate object key according to S3 rules
*/
private function validate(): void
{
$length = strlen($this->value);
// Length check
if ($length > self::MAX_LENGTH) {
throw new InvalidArgumentException(
"Object key cannot exceed " . self::MAX_LENGTH . " characters (got {$length})"
);
}
// Empty keys are allowed (for root-level objects)
if ($length === 0) {
return;
}
// Check for control characters (0x00-0x1F, 0x7F)
if (preg_match('/[\x00-\x1F\x7F]/', $this->value)) {
throw new InvalidArgumentException('Object key cannot contain control characters');
}
// Validate UTF-8 encoding
if (! mb_check_encoding($this->value, 'UTF-8')) {
throw new InvalidArgumentException('Object key must be valid UTF-8');
}
// S3 allows most characters, but we should avoid some problematic ones
// Note: S3 actually allows most characters, but we'll be conservative
}
/**
* Static validation method (for pre-validation without creating object)
*/
public static function isValid(string $key): bool
{
try {
new self($key);
return true;
} catch (InvalidArgumentException) {
return false;
}
}
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\ValueObjects;
use InvalidArgumentException;
/**
* Object Metadata Value Object
*
* Type-safe metadata container for storage objects.
* Provides immutable access to key-value metadata pairs.
*/
final readonly class ObjectMetadata
{
/**
* @var array<string, mixed>
*/
private array $data;
public function __construct(array $data)
{
$this->validate($data);
$this->data = $data;
}
/**
* Create ObjectMetadata from array
*
* @param array<string, mixed> $metadata
*/
public static function fromArray(array $metadata): self
{
return new self($metadata);
}
/**
* Create empty ObjectMetadata
*/
public static function empty(): self
{
return new self([]);
}
/**
* Get metadata value by key
*
* @return mixed
*/
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
/**
* Check if metadata key exists
*/
public function has(string $key): bool
{
return isset($this->data[$key]);
}
/**
* Get all metadata as array
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return $this->data;
}
/**
* Create new instance with additional/updated metadata
*/
public function with(string $key, mixed $value): self
{
$newData = $this->data;
$newData[$key] = $value;
return new self($newData);
}
/**
* Create new instance without specified key
*/
public function without(string $key): self
{
$newData = $this->data;
unset($newData[$key]);
return new self($newData);
}
/**
* Merge with another ObjectMetadata
*/
public function merge(self $other): self
{
return new self(array_merge($this->data, $other->data));
}
/**
* Check if metadata is empty
*/
public function isEmpty(): bool
{
return empty($this->data);
}
/**
* Get count of metadata entries
*/
public function count(): int
{
return count($this->data);
}
/**
* Get all keys
*
* @return array<string>
*/
public function keys(): array
{
return array_keys($this->data);
}
/**
* Get all values
*
* @return array<mixed>
*/
public function values(): array
{
return array_values($this->data);
}
/**
* Validate metadata structure
*
* @param array<string, mixed> $data
*/
private function validate(array $data): void
{
foreach ($data as $key => $value) {
// Key validation
if (! is_string($key)) {
throw new InvalidArgumentException('Metadata keys must be strings');
}
if (empty($key)) {
throw new InvalidArgumentException('Metadata keys cannot be empty');
}
if (strlen($key) > 255) {
throw new InvalidArgumentException('Metadata keys cannot exceed 255 characters');
}
// Value validation - only allow serializable types
if (! $this->isValidValue($value)) {
throw new InvalidArgumentException(
"Metadata value for key '{$key}' must be serializable (string, int, float, bool, null, or array of these)"
);
}
}
}
/**
* Check if value is valid for metadata
*/
private function isValidValue(mixed $value): bool
{
if (is_scalar($value) || $value === null) {
return true;
}
if (is_array($value)) {
foreach ($value as $item) {
if (! $this->isValidValue($item)) {
return false;
}
}
return true;
}
return false;
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\ValueObjects;
use InvalidArgumentException;
/**
* Version ID Value Object
*
* Type-safe S3 object version identifier.
* Used for S3 object versioning support.
*/
final readonly class VersionId
{
private const MAX_LENGTH = 255;
private const NULL_VERSION = 'null';
private function __construct(
private string $value
) {
$this->validate();
}
/**
* Create VersionId from string
*/
public static function fromString(string $versionId): self
{
return new self($versionId);
}
/**
* Create null version (represents current version or no versioning)
*/
public static function null(): self
{
return new self(self::NULL_VERSION);
}
/**
* Get version ID as string
*/
public function toString(): string
{
return $this->value;
}
/**
* String representation
*/
public function __toString(): string
{
return $this->value;
}
/**
* Check if two version IDs are equal
*/
public function equals(self $other): bool
{
return $this->value === $other->value;
}
/**
* Check if this is a null version
*/
public function isNullVersion(): bool
{
return $this->value === self::NULL_VERSION;
}
/**
* Validate version ID
*/
private function validate(): void
{
$length = strlen($this->value);
// Cannot be empty
if ($length === 0) {
throw new InvalidArgumentException('Version ID cannot be empty');
}
// Length check
if ($length > self::MAX_LENGTH) {
throw new InvalidArgumentException(
"Version ID cannot exceed " . self::MAX_LENGTH . " characters (got {$length})"
);
}
// S3 version IDs are typically alphanumeric with some special characters
// We'll be permissive but validate basic constraints
if (preg_match('/[\x00-\x1F\x7F]/', $this->value)) {
throw new InvalidArgumentException('Version ID cannot contain control characters');
}
}
/**
* Static validation method (for pre-validation without creating object)
*/
public static function isValid(string $versionId): bool
{
try {
new self($versionId);
return true;
} catch (InvalidArgumentException) {
return false;
}
}
}

View File

@@ -34,8 +34,15 @@ final readonly class DiscoveryTokenizer
$name = $this->findNextIdentifier($tokens, $token); $name = $this->findNextIdentifier($tokens, $token);
if ($name) { if ($name) {
// Validate: Only extract classes that are declared in the current namespace context
// The currentNamespace should match the file's namespace declaration
$currentNamespace = $context->currentNamespace ?? null;
// For class FQN, only use namespace + name (not currentClass) // For class FQN, only use namespace + name (not currentClass)
$fqn = $context->currentNamespace ? $context->currentNamespace . '\\' . $name : $name; // IMPORTANT: Only use currentNamespace if it's actually set (from namespace declaration)
// This ensures we only extract classes that are declared in this file's namespace
$fqn = $currentNamespace ? $currentNamespace . '\\' . $name : $name;
$classes[] = [ $classes[] = [
'type' => match($token->id) { 'type' => match($token->id) {
T_CLASS => 'class', T_CLASS => 'class',
@@ -45,7 +52,7 @@ final readonly class DiscoveryTokenizer
default => 'unknown' default => 'unknown'
}, },
'name' => $name, 'name' => $name,
'namespace' => $context->currentNamespace, 'namespace' => $currentNamespace,
'fqn' => $fqn, 'fqn' => $fqn,
'line' => $token->line, 'line' => $token->line,
]; ];
@@ -140,20 +147,58 @@ final readonly class DiscoveryTokenizer
} }
/** /**
* Find next identifier after a token * Find next identifier after a token (class/interface/trait/enum name)
* Stops at structural elements to avoid extracting method names
*/ */
private function findNextIdentifier(TokenCollection $tokens, $startToken): ?string private function findNextIdentifier(TokenCollection $tokens, $startToken): ?string
{ {
$found = false; $tokensArray = $tokens->toArray();
foreach ($tokens as $token) { $startIndex = null;
if ($found && $token->id === T_STRING) {
return $token->value; // Find the start token index
} foreach ($tokensArray as $index => $token) {
if ($token === $startToken) { if ($token === $startToken) {
$found = true; $startIndex = $index;
break;
} }
} }
if ($startIndex === null) {
return null;
}
// Tokens that are allowed before the class name (modifiers)
$allowedModifiers = [T_FINAL, T_ABSTRACT, T_READONLY];
// Tokens that stop the search (structural boundaries)
$stopTokens = [
'{', ';', // Class body start or statement end
T_EXTENDS, T_IMPLEMENTS, // Inheritance keywords
T_FUNCTION, T_FN, // Function/method declarations
T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM, // Other class-like declarations
];
// Iterate through tokens after the start token
for ($i = $startIndex + 1; $i < count($tokensArray); $i++) {
$token = $tokensArray[$i];
// Stop at structural boundaries
if ($token->value === '{' || $token->value === ';' ||
$token->is($stopTokens)) {
break;
}
// Skip whitespace and allowed modifiers
if ($token->id === T_WHITESPACE || $token->is($allowedModifiers)) {
continue;
}
// Found the identifier (class name)
if ($token->id === T_STRING) {
return $token->value;
}
}
return null; return null;
} }

View File

@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Gitea;
use App\Framework\Http\Method;
use App\Infrastructure\Api\Gitea\ValueObjects\{
WorkflowList,
WorkflowRunsList,
WorkflowRun,
Workflow,
RunId
};
final readonly class ActionService
{
public function __construct(
private GiteaApiClient $apiClient
) {
}
/**
* Listet alle Workflows eines Repositories
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @return array Liste der Workflows
*/
public function listWorkflows(string $owner, string $repo): array
{
return $this->apiClient->request(
Method::GET,
"repos/{$owner}/{$repo}/actions/workflows"
);
}
/**
* Listet alle Workflow Runs eines Repositories
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @param array $options Optionale Parameter (status, workflow_id, page, limit, etc.)
* @return array Liste der Runs
*/
public function listRuns(string $owner, string $repo, array $options = []): array
{
return $this->apiClient->request(
Method::GET,
"repos/{$owner}/{$repo}/actions/runs",
[],
$options
);
}
/**
* Ruft Details eines Workflow Runs ab
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @param int $runId Run ID
* @return array Run-Details
*/
public function getRun(string $owner, string $repo, int $runId): array
{
return $this->apiClient->request(
Method::GET,
"repos/{$owner}/{$repo}/actions/runs/{$runId}"
);
}
/**
* Triggert einen Workflow manuell
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @param string $workflowId Workflow ID oder Dateiname (z.B. "ci.yml")
* @param array $inputs Optionale Inputs für den Workflow
* @param string|null $ref Optional: Branch/Tag/Commit SHA (Standard: default branch)
* @return array Response (normalerweise 204 No Content)
*/
public function triggerWorkflow(
string $owner,
string $repo,
string $workflowId,
array $inputs = [],
?string $ref = null
): array {
$data = [];
if (! empty($inputs)) {
$data['inputs'] = $inputs;
}
if ($ref !== null) {
$data['ref'] = $ref;
}
return $this->apiClient->request(
Method::POST,
"repos/{$owner}/{$repo}/actions/workflows/{$workflowId}/dispatches",
$data
);
}
/**
* Bricht einen laufenden Workflow Run ab
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @param int $runId Run ID
* @return array Response
*/
public function cancelRun(string $owner, string $repo, int $runId): array
{
return $this->apiClient->request(
Method::POST,
"repos/{$owner}/{$repo}/actions/runs/{$runId}/cancel"
);
}
/**
* Ruft die Logs eines Workflow Runs ab
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @param int $runId Run ID
* @return string Logs als Text (oder Array wenn JSON)
*/
public function getLogs(string $owner, string $repo, int $runId): string
{
$response = $this->apiClient->sendRawRequest(
Method::GET,
"repos/{$owner}/{$repo}/actions/runs/{$runId}/logs"
);
return $response->body;
}
/**
* Ruft den Status eines Workflow Runs ab (Helper-Methode)
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @param int $runId Run ID
* @return string Status (z.B. "success", "failure", "cancelled", "running", "waiting")
*/
public function getRunStatus(string $owner, string $repo, int $runId): string
{
$run = $this->getRun($owner, $repo, $runId);
return $run['status'] ?? 'unknown';
}
// ========================================================================
// Typed Value Object Methods (Parallel Implementation)
// ========================================================================
/**
* Listet alle Workflows eines Repositories (typed)
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @return WorkflowList Type-safe Workflow Liste
*/
public function listWorkflowsTyped(string $owner, string $repo): WorkflowList
{
$data = $this->listWorkflows($owner, $repo);
return WorkflowList::fromApiResponse($data);
}
/**
* Listet alle Workflow Runs eines Repositories (typed)
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @param array $options Optionale Parameter (status, workflow_id, page, limit, etc.)
* @return WorkflowRunsList Type-safe Workflow Runs Liste
*/
public function listRunsTyped(string $owner, string $repo, array $options = []): WorkflowRunsList
{
$data = $this->listRuns($owner, $repo, $options);
return WorkflowRunsList::fromApiResponse($data);
}
/**
* Ruft Details eines Workflow Runs ab (typed)
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @param int $runId Run ID
* @return WorkflowRun Type-safe Workflow Run
*/
public function getRunTyped(string $owner, string $repo, int $runId): WorkflowRun
{
$data = $this->getRun($owner, $repo, $runId);
return WorkflowRun::fromApiResponse($data);
}
/**
* Ruft Details eines Workflow Runs ab via RunId (typed)
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @param RunId $runId Run ID Value Object
* @return WorkflowRun Type-safe Workflow Run
*/
public function getRunByIdTyped(string $owner, string $repo, RunId $runId): WorkflowRun
{
return $this->getRunTyped($owner, $repo, $runId->value);
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Gitea;
use App\Framework\Api\ApiException;
use App\Framework\Http\Method;
use App\Framework\HttpClient\AuthConfig;
use App\Framework\HttpClient\ClientOptions;
use App\Framework\HttpClient\ClientRequest;
use App\Framework\HttpClient\ClientResponse;
use App\Framework\HttpClient\HttpClient;
final readonly class GiteaApiClient
{
private ClientOptions $defaultOptions;
public function __construct(
private GiteaConfig $config,
private HttpClient $httpClient
) {
$authConfig = $this->buildAuthConfig();
$this->defaultOptions = new ClientOptions(
timeout: (int) $this->config->timeout,
auth: $authConfig
);
}
/**
* Sendet eine API-Anfrage und gibt JSON-Daten zurück
*/
public function request(
Method $method,
string $endpoint,
array $data = [],
array $queryParams = []
): array {
$response = $this->sendRawRequest($method, $endpoint, $data, $queryParams);
return $this->handleResponse($response);
}
/**
* Sendet eine API-Anfrage und gibt raw Response zurück
*/
public function sendRawRequest(
Method $method,
string $endpoint,
array $data = [],
array $queryParams = []
): ClientResponse {
$baseUrl = rtrim($this->config->baseUrl, '/');
$url = $baseUrl . '/api/v1/' . ltrim($endpoint, '/');
$options = $this->defaultOptions;
if (! empty($queryParams)) {
$options = $options->with(['query' => $queryParams]);
}
if (in_array($method, [Method::GET, Method::DELETE]) && ! empty($data)) {
$options = $options->with(['query' => array_merge($options->query, $data)]);
$data = [];
}
$request = empty($data)
? new ClientRequest($method, $url, options: $options)
: ClientRequest::json($method, $url, $data, $options);
$response = $this->httpClient->send($request);
if (! $response->isSuccessful()) {
$this->throwApiException($response);
}
return $response;
}
/**
* Behandelt API-Response
*/
private function handleResponse(ClientResponse $response): array
{
if (! $response->isJson()) {
throw new ApiException(
'Expected JSON response, got: ' . $response->getContentType(),
0,
$response
);
}
try {
return $response->json();
} catch (\Exception $e) {
throw new ApiException(
'Invalid JSON response: ' . $e->getMessage(),
0,
$response
);
}
}
/**
* Wirft API-Exception
*/
private function throwApiException(ClientResponse $response): never
{
$data = [];
if ($response->isJson()) {
try {
$data = $response->json();
} catch (\Exception) {
// JSON parsing failed
}
}
$message = $this->formatErrorMessage($data, $response);
throw new ApiException($message, $response->status->value, $response);
}
/**
* Formatiert Fehlermeldung
*/
private function formatErrorMessage(array $responseData, ClientResponse $response): string
{
if (isset($responseData['message'])) {
return 'Gitea API Error: ' . $responseData['message'];
}
if (isset($responseData['error'])) {
return 'Gitea API Error: ' . $responseData['error'];
}
return "Gitea API Error (HTTP {$response->status->value}): " .
substr($response->body, 0, 200);
}
/**
* Erstellt AuthConfig basierend auf GiteaConfig
*/
private function buildAuthConfig(): AuthConfig
{
if ($this->config->token !== null) {
return AuthConfig::bearer($this->config->token);
}
if ($this->config->username !== null && $this->config->password !== null) {
return AuthConfig::basic($this->config->username, $this->config->password);
}
throw new \InvalidArgumentException(
'Either token or username+password must be provided for Gitea authentication'
);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Gitea;
use App\Framework\HttpClient\HttpClient;
final readonly class GiteaClient
{
public RepositoryService $repositories;
public UserService $users;
public IssueService $issues;
public ActionService $actions;
public function __construct(
GiteaConfig $config,
HttpClient $httpClient
) {
$apiClient = new GiteaApiClient($config, $httpClient);
$this->repositories = new RepositoryService($apiClient);
$this->users = new UserService($apiClient);
$this->issues = new IssueService($apiClient);
$this->actions = new ActionService($apiClient);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Gitea;
use App\Framework\Config\Environment;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\HttpClient\CurlHttpClient;
use App\Framework\HttpClient\HttpClient;
final readonly class GiteaClientInitializer
{
#[Initializer]
public function __invoke(Container $container): GiteaClient
{
$env = $container->get(Environment::class);
$httpClient = $container->get(HttpClient::class) ?? new CurlHttpClient();
$baseUrl = $env->get('GITEA_URL', 'https://git.michaelschiemer.de');
$token = $env->get('GITEA_TOKEN');
$username = $env->get('GITEA_USERNAME');
$password = $env->get('GITEA_PASSWORD');
$timeout = (float) $env->get('GITEA_TIMEOUT', '30.0');
$config = new GiteaConfig(
baseUrl: $baseUrl,
token: $token,
username: $username,
password: $password,
timeout: $timeout
);
return new GiteaClient($config, $httpClient);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Gitea;
final readonly class GiteaConfig
{
public function __construct(
public string $baseUrl,
public ?string $token = null,
public ?string $username = null,
public ?string $password = null,
public float $timeout = 30.0
) {
if ($this->token === null && ($this->username === null || $this->password === null)) {
throw new \InvalidArgumentException(
'Either token or username+password must be provided for Gitea authentication'
);
}
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Gitea;
use App\Framework\Http\Method;
final readonly class IssueService
{
public function __construct(
private GiteaApiClient $apiClient
) {
}
/**
* Listet alle Issues eines Repositories
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @param array $options Optionale Parameter (state, labels, page, limit, etc.)
* @return array Liste der Issues
*/
public function list(string $owner, string $repo, array $options = []): array
{
return $this->apiClient->request(
Method::GET,
"repos/{$owner}/{$repo}/issues",
[],
$options
);
}
/**
* Ruft ein Issue ab
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @param int $index Issue Index
* @return array Issue-Daten
*/
public function get(string $owner, string $repo, int $index): array
{
return $this->apiClient->request(
Method::GET,
"repos/{$owner}/{$repo}/issues/{$index}"
);
}
/**
* Erstellt ein neues Issue
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @param array $data Issue-Daten (title, body, assignees, labels, etc.)
* @return array Erstelltes Issue
*/
public function create(string $owner, string $repo, array $data): array
{
return $this->apiClient->request(
Method::POST,
"repos/{$owner}/{$repo}/issues",
$data
);
}
/**
* Aktualisiert ein Issue
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @param int $index Issue Index
* @param array $data Zu aktualisierende Daten
* @return array Aktualisiertes Issue
*/
public function update(string $owner, string $repo, int $index, array $data): array
{
return $this->apiClient->request(
Method::PATCH,
"repos/{$owner}/{$repo}/issues/{$index}",
$data
);
}
/**
* Schließt ein Issue
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @param int $index Issue Index
* @return array Aktualisiertes Issue
*/
public function close(string $owner, string $repo, int $index): array
{
return $this->update($owner, $repo, $index, ['state' => 'closed']);
}
}

View File

@@ -0,0 +1,501 @@
# Gitea API Client
## Übersicht
Dieser Client bietet eine strukturierte Schnittstelle für die Kommunikation mit der Gitea API v1. Er unterstützt Basis-Operationen für Repositories, User und Issues.
## Architektur
Der Client folgt dem Service-Layer-Pattern:
- **GiteaApiClient**: Low-level API Client für HTTP-Kommunikation
- **RepositoryService**: Repository-Verwaltung
- **UserService**: User-Verwaltung
- **IssueService**: Issue-Verwaltung
- **ActionService**: Workflow/Action Management für Testing und Trigger
- **GiteaClient**: Facade, die alle Services bereitstellt
## Konfiguration
### Environment Variables
```env
GITEA_URL=https://git.michaelschiemer.de
GITEA_TOKEN=your_access_token
# ODER
GITEA_USERNAME=your_username
GITEA_PASSWORD=your_password
GITEA_TIMEOUT=30.0
```
### Manuelle Konfiguration
```php
use App\Infrastructure\Api\Gitea\GiteaClient;
use App\Infrastructure\Api\Gitea\GiteaConfig;
use App\Framework\HttpClient\CurlHttpClient;
// Mit Token
$config = new GiteaConfig(
baseUrl: 'https://git.michaelschiemer.de',
token: 'your_access_token'
);
// Mit Username/Password
$config = new GiteaConfig(
baseUrl: 'https://git.michaelschiemer.de',
username: 'your_username',
password: 'your_password'
);
$client = new GiteaClient($config, new CurlHttpClient());
```
### Dependency Injection
```php
use App\Infrastructure\Api\Gitea\GiteaClient;
// Der GiteaClientInitializer lädt automatisch die Konfiguration aus Environment
$client = $container->get(GiteaClient::class);
```
## Quick Start
```php
use App\Infrastructure\Api\Gitea\GiteaClient;
$client = $container->get(GiteaClient::class);
// Repository-Operationen
$repos = $client->repositories->list();
$repo = $client->repositories->get('owner', 'repo-name');
// User-Operationen
$currentUser = $client->users->getCurrent();
$user = $client->users->get('username');
// Issue-Operationen
$issues = $client->issues->list('owner', 'repo-name');
$issue = $client->issues->get('owner', 'repo-name', 1);
// Action/Workflow-Operationen
$workflows = $client->actions->listWorkflows('owner', 'repo-name');
$runs = $client->actions->listRuns('owner', 'repo-name');
$client->actions->triggerWorkflow('owner', 'repo-name', 'ci.yml');
```
## API Reference
### RepositoryService
#### list()
Listet alle Repositories des authentifizierten Users.
```php
$repos = $client->repositories->list();
$repos = $client->repositories->list(['page' => 1, 'limit' => 50]);
```
#### get(string $owner, string $repo)
Ruft ein Repository ab.
```php
$repo = $client->repositories->get('owner', 'repo-name');
```
#### create(array $data)
Erstellt ein neues Repository.
```php
$repo = $client->repositories->create([
'name' => 'my-repo',
'description' => 'My repository description',
'private' => true,
'auto_init' => false,
'default_branch' => 'main'
]);
```
#### update(string $owner, string $repo, array $data)
Aktualisiert ein Repository.
```php
$repo = $client->repositories->update('owner', 'repo-name', [
'description' => 'Updated description',
'private' => false
]);
```
#### delete(string $owner, string $repo)
Löscht ein Repository.
```php
$client->repositories->delete('owner', 'repo-name');
```
### UserService
#### getCurrent()
Ruft den aktuellen authentifizierten User ab.
```php
$user = $client->users->getCurrent();
```
#### get(string $username)
Ruft einen User anhand des Usernames ab.
```php
$user = $client->users->get('username');
```
#### list(array $options = [])
Sucht nach Usern.
```php
$users = $client->users->list(['q' => 'search-term', 'page' => 1, 'limit' => 50]);
```
### IssueService
#### list(string $owner, string $repo, array $options = [])
Listet alle Issues eines Repositories.
```php
$issues = $client->issues->list('owner', 'repo-name');
$issues = $client->issues->list('owner', 'repo-name', [
'state' => 'open',
'labels' => 'bug',
'page' => 1,
'limit' => 50
]);
```
#### get(string $owner, string $repo, int $index)
Ruft ein Issue ab.
```php
$issue = $client->issues->get('owner', 'repo-name', 1);
```
#### create(string $owner, string $repo, array $data)
Erstellt ein neues Issue.
```php
$issue = $client->issues->create('owner', 'repo-name', [
'title' => 'Bug Report',
'body' => 'Issue description',
'assignees' => ['username'],
'labels' => [1, 2, 3]
]);
```
#### update(string $owner, string $repo, int $index, array $data)
Aktualisiert ein Issue.
```php
$issue = $client->issues->update('owner', 'repo-name', 1, [
'title' => 'Updated title',
'body' => 'Updated description',
'state' => 'open'
]);
```
#### close(string $owner, string $repo, int $index)
Schließt ein Issue.
```php
$issue = $client->issues->close('owner', 'repo-name', 1);
```
### ActionService
#### listWorkflows(string $owner, string $repo)
Listet alle Workflows eines Repositories.
```php
$workflows = $client->actions->listWorkflows('owner', 'repo-name');
```
#### listRuns(string $owner, string $repo, array $options = [])
Listet alle Workflow Runs eines Repositories.
```php
$runs = $client->actions->listRuns('owner', 'repo-name');
$runs = $client->actions->listRuns('owner', 'repo-name', [
'status' => 'success',
'workflow_id' => 1,
'page' => 1,
'limit' => 50
]);
```
#### getRun(string $owner, string $repo, int $runId)
Ruft Details eines Workflow Runs ab.
```php
$run = $client->actions->getRun('owner', 'repo-name', 123);
```
#### triggerWorkflow(string $owner, string $repo, string $workflowId, array $inputs = [], ?string $ref = null)
Triggert einen Workflow manuell.
```php
// Workflow ohne Inputs triggern
$client->actions->triggerWorkflow('owner', 'repo-name', 'ci.yml');
// Workflow mit Inputs triggern
$client->actions->triggerWorkflow('owner', 'repo-name', 'deploy.yml', [
'environment' => 'production',
'skip_tests' => false
]);
// Workflow auf spezifischem Branch triggern
$client->actions->triggerWorkflow('owner', 'repo-name', 'ci.yml', [], 'develop');
```
#### cancelRun(string $owner, string $repo, int $runId)
Bricht einen laufenden Workflow Run ab.
```php
$client->actions->cancelRun('owner', 'repo-name', 123);
```
#### getLogs(string $owner, string $repo, int $runId)
Ruft die Logs eines Workflow Runs ab.
```php
$logs = $client->actions->getLogs('owner', 'repo-name', 123);
echo $logs; // Logs als Text
```
#### getRunStatus(string $owner, string $repo, int $runId)
Ruft den Status eines Workflow Runs ab (Helper-Methode).
```php
$status = $client->actions->getRunStatus('owner', 'repo-name', 123);
// Mögliche Werte: "success", "failure", "cancelled", "running", "waiting"
```
## Authentifizierung
Der Client unterstützt zwei Authentifizierungsmethoden:
### Token-Authentifizierung (empfohlen)
```php
$config = new GiteaConfig(
baseUrl: 'https://git.michaelschiemer.de',
token: 'your_access_token'
);
```
### Basic Authentication
```php
$config = new GiteaConfig(
baseUrl: 'https://git.michaelschiemer.de',
username: 'your_username',
password: 'your_password'
);
```
## Fehlerbehandlung
Alle API-Clients werfen eine standardisierte `ApiException` bei Fehlern:
```php
use App\Framework\Api\ApiException;
try {
$repo = $client->repositories->get('owner', 'repo-name');
} catch (ApiException $e) {
echo "Error: " . $e->getMessage();
echo "Status Code: " . $e->getCode();
// Zugriff auf Response-Daten
$responseData = $e->getResponseData();
}
```
## API-Endpunkte
Der Client nutzt die folgenden Gitea API v1 Endpunkte:
- `GET /api/v1/user/repos` - Repositories auflisten
- `GET /api/v1/repos/{owner}/{repo}` - Repository abrufen
- `POST /api/v1/user/repos` - Repository erstellen
- `PATCH /api/v1/repos/{owner}/{repo}` - Repository aktualisieren
- `DELETE /api/v1/repos/{owner}/{repo}` - Repository löschen
- `GET /api/v1/user` - Aktueller User
- `GET /api/v1/users/{username}` - User abrufen
- `GET /api/v1/users/search` - User suchen
- `GET /api/v1/repos/{owner}/{repo}/issues` - Issues auflisten
- `GET /api/v1/repos/{owner}/{repo}/issues/{index}` - Issue abrufen
- `POST /api/v1/repos/{owner}/{repo}/issues` - Issue erstellen
- `PATCH /api/v1/repos/{owner}/{repo}/issues/{index}` - Issue aktualisieren
- `GET /api/v1/repos/{owner}/{repo}/actions/workflows` - Workflows auflisten
- `GET /api/v1/repos/{owner}/{repo}/actions/runs` - Runs auflisten
- `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}` - Run Details
- `POST /api/v1/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches` - Workflow triggern
- `POST /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/cancel` - Run abbrechen
- `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/logs` - Run Logs
## Beispiele
### Repository-Verwaltung
```php
// Alle Repositories auflisten
$repos = $client->repositories->list();
// Neues Repository erstellen
$newRepo = $client->repositories->create([
'name' => 'my-new-repo',
'description' => 'A new repository',
'private' => true,
'auto_init' => true
]);
// Repository aktualisieren
$updatedRepo = $client->repositories->update('owner', 'repo-name', [
'description' => 'Updated description'
]);
// Repository löschen
$client->repositories->delete('owner', 'repo-name');
```
### Issue-Verwaltung
```php
// Alle Issues auflisten
$openIssues = $client->issues->list('owner', 'repo-name', ['state' => 'open']);
// Neues Issue erstellen
$issue = $client->issues->create('owner', 'repo-name', [
'title' => 'Bug: Something is broken',
'body' => 'Detailed description of the bug',
'labels' => [1] // Label ID
]);
// Issue schließen
$closedIssue = $client->issues->close('owner', 'repo-name', 1);
```
### User-Informationen
```php
// Aktuellen User abrufen
$currentUser = $client->users->getCurrent();
echo "Logged in as: " . $currentUser['login'];
// User suchen
$users = $client->users->list(['q' => 'john', 'limit' => 10]);
```
### Workflow/Action Management
```php
// Alle Workflows auflisten
$workflows = $client->actions->listWorkflows('owner', 'repo-name');
// Alle Runs auflisten
$runs = $client->actions->listRuns('owner', 'repo-name', ['status' => 'running']);
// Workflow manuell triggern (für Testing)
$client->actions->triggerWorkflow('owner', 'repo-name', 'ci.yml', [
'skip_tests' => false,
'environment' => 'staging'
], 'main');
// Run Status prüfen
$status = $client->actions->getRunStatus('owner', 'repo-name', $runId);
if ($status === 'running') {
echo "Workflow läuft noch...";
}
// Run Logs abrufen
$logs = $client->actions->getLogs('owner', 'repo-name', $runId);
file_put_contents('workflow-logs.txt', $logs);
// Laufenden Run abbrechen
$client->actions->cancelRun('owner', 'repo-name', $runId);
```
### Workflow Testing Workflow
```php
// 1. Workflow triggern
$client->actions->triggerWorkflow('owner', 'repo-name', 'test.yml', [], 'test-branch');
// 2. Warten und Status prüfen
do {
sleep(5);
$runs = $client->actions->listRuns('owner', 'repo-name', ['limit' => 1]);
$latestRun = $runs['workflow_runs'][0] ?? null;
$status = $latestRun['status'] ?? 'unknown';
} while ($status === 'running' || $status === 'waiting');
// 3. Logs abrufen wenn abgeschlossen
if ($latestRun) {
$logs = $client->actions->getLogs('owner', 'repo-name', $latestRun['id']);
echo $logs;
}
```
## Best Practices
1. **Token-Authentifizierung bevorzugen**: Token sind sicherer als Username/Password
2. **Fehlerbehandlung**: Immer `ApiException` abfangen
3. **Pagination**: Bei großen Listen Pagination-Parameter verwenden
4. **Rate Limiting**: Gitea API Rate Limits beachten
5. **Dependency Injection**: Client über DI Container verwenden
## Troubleshooting
### "Invalid authentication credentials"
- Überprüfe, ob Token oder Username/Password korrekt sind
- Stelle sicher, dass der Token die benötigten Berechtigungen hat
### "Repository not found"
- Überprüfe, ob Owner und Repository-Name korrekt sind
- Stelle sicher, dass der User Zugriff auf das Repository hat
### "API rate limit exceeded"
- Reduziere die Anzahl der API-Aufrufe
- Implementiere Retry-Logik mit Exponential Backoff
### "Workflow not found"
- Überprüfe, ob der Workflow-Dateiname korrekt ist (z.B. "ci.yml")
- Stelle sicher, dass der Workflow im `.gitea/workflows/` Verzeichnis existiert
### "Workflow run already completed"
- Workflow Runs können nur abgebrochen werden, wenn sie noch laufen
- Prüfe den Status mit `getRunStatus()` vor dem Abbrechen

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Gitea;
use App\Framework\Http\Method;
final readonly class RepositoryService
{
public function __construct(
private GiteaApiClient $apiClient
) {
}
/**
* Listet alle Repositories des authentifizierten Users
*
* @param array $options Optionale Parameter (page, limit, etc.)
* @return array Liste der Repositories
*/
public function list(array $options = []): array
{
return $this->apiClient->request(
Method::GET,
'user/repos',
[],
$options
);
}
/**
* Ruft ein Repository ab
*
* @param string $owner Repository Owner (Username oder Organization)
* @param string $repo Repository Name
* @return array Repository-Daten
*/
public function get(string $owner, string $repo): array
{
return $this->apiClient->request(
Method::GET,
"repos/{$owner}/{$repo}"
);
}
/**
* Erstellt ein neues Repository
*
* @param array $data Repository-Daten (name, description, private, auto_init, etc.)
* @return array Erstelltes Repository
*/
public function create(array $data): array
{
return $this->apiClient->request(
Method::POST,
'user/repos',
$data
);
}
/**
* Aktualisiert ein Repository
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @param array $data Zu aktualisierende Daten
* @return array Aktualisiertes Repository
*/
public function update(string $owner, string $repo, array $data): array
{
return $this->apiClient->request(
Method::PATCH,
"repos/{$owner}/{$repo}",
$data
);
}
/**
* Löscht ein Repository
*
* @param string $owner Repository Owner
* @param string $repo Repository Name
* @return void
*/
public function delete(string $owner, string $repo): void
{
$this->apiClient->sendRawRequest(
Method::DELETE,
"repos/{$owner}/{$repo}"
);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Gitea;
use App\Framework\Http\Method;
final readonly class UserService
{
public function __construct(
private GiteaApiClient $apiClient
) {
}
/**
* Ruft den aktuellen authentifizierten User ab
*
* @return array User-Daten
*/
public function getCurrent(): array
{
return $this->apiClient->request(
Method::GET,
'user'
);
}
/**
* Ruft einen User anhand des Usernames ab
*
* @param string $username Username
* @return array User-Daten
*/
public function get(string $username): array
{
return $this->apiClient->request(
Method::GET,
"users/{$username}"
);
}
/**
* Sucht nach Usern
*
* @param array $options Optionale Parameter (q, page, limit, etc.)
* @return array Liste der User
*/
public function list(array $options = []): array
{
return $this->apiClient->request(
Method::GET,
'users/search',
[],
$options
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Gitea\ValueObjects;
/**
* Workflow Run Conclusion
*
* Represents the final outcome of a completed workflow run.
*/
enum RunConclusion: string
{
case SUCCESS = 'success';
case FAILURE = 'failure';
case CANCELLED = 'cancelled';
case SKIPPED = 'skipped';
/**
* Check if the conclusion indicates success
*/
public function isSuccessful(): bool
{
return $this === self::SUCCESS;
}
/**
* Check if the conclusion indicates failure
*/
public function isFailed(): bool
{
return $this === self::FAILURE;
}
/**
* Check if the run was manually cancelled
*/
public function wasCancelled(): bool
{
return $this === self::CANCELLED;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Gitea\ValueObjects;
/**
* Workflow Run ID
*
* Type-safe wrapper for workflow run identifiers.
*/
final readonly class RunId implements \Stringable
{
public function __construct(
public int $value
) {
if ($value <= 0) {
throw new \InvalidArgumentException('Run ID must be a positive integer');
}
}
/**
* Create RunId from string representation
*/
public static function fromString(string $id): self
{
if (!is_numeric($id)) {
throw new \InvalidArgumentException('Run ID must be numeric');
}
return new self((int) $id);
}
/**
* Create RunId from API response
*/
public static function fromApiResponse(int|string $id): self
{
if (is_string($id)) {
return self::fromString($id);
}
return new self($id);
}
/**
* Check equality with another RunId
*/
public function equals(self $other): bool
{
return $this->value === $other->value;
}
/**
* Convert to string representation
*/
public function toString(): string
{
return (string) $this->value;
}
public function __toString(): string
{
return $this->toString();
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Gitea\ValueObjects;
/**
* Workflow Run Status
*
* Represents the current execution state of a workflow run.
*/
enum RunStatus: string
{
case COMPLETED = 'completed';
case IN_PROGRESS = 'in_progress';
case QUEUED = 'queued';
case WAITING = 'waiting';
/**
* Check if the run is in a terminal state
*/
public function isTerminal(): bool
{
return $this === self::COMPLETED;
}
/**
* Check if the run is actively executing
*/
public function isActive(): bool
{
return match ($this) {
self::IN_PROGRESS, self::QUEUED, self::WAITING => true,
self::COMPLETED => false,
};
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Gitea\ValueObjects;
/**
* Workflow
*
* Represents a workflow definition in Gitea Actions.
*/
final readonly class Workflow
{
public function __construct(
public int $id,
public string $name,
public string $path,
public string $state,
) {}
/**
* Create Workflow from Gitea API response
*/
public static function fromApiResponse(array $data): self
{
return new self(
id: (int) $data['id'],
name: $data['name'],
path: $data['path'],
state: $data['state'],
);
}
/**
* Check if the workflow is active
*/
public function isActive(): bool
{
return $this->state === 'active';
}
/**
* Check if the workflow is disabled
*/
public function isDisabled(): bool
{
return $this->state === 'disabled';
}
/**
* Get the workflow file name
*/
public function getFileName(): string
{
return basename($this->path);
}
/**
* Convert to array representation
*/
public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'path' => $this->path,
'state' => $this->state,
];
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Gitea\ValueObjects;
use IteratorAggregate;
use Countable;
use ArrayIterator;
/**
* Workflow List
*
* Type-safe collection of Workflow objects.
*/
final readonly class WorkflowList implements IteratorAggregate, Countable
{
/** @var Workflow[] */
private array $workflows;
/**
* @param Workflow[] $workflows
*/
public function __construct(array $workflows)
{
// Validate all items are Workflow instances
foreach ($workflows as $workflow) {
if (!$workflow instanceof Workflow) {
throw new \InvalidArgumentException(
'All items must be Workflow instances'
);
}
}
$this->workflows = array_values($workflows);
}
/**
* Create from Gitea API response
*/
public static function fromApiResponse(array $data): self
{
$workflows = [];
foreach ($data['workflows'] ?? [] as $workflowData) {
$workflows[] = Workflow::fromApiResponse($workflowData);
}
return new self($workflows);
}
/**
* Get all workflows
*
* @return Workflow[]
*/
public function all(): array
{
return $this->workflows;
}
/**
* Get active workflows
*
* @return Workflow[]
*/
public function active(): array
{
return array_filter(
$this->workflows,
fn(Workflow $workflow) => $workflow->isActive()
);
}
/**
* Get disabled workflows
*
* @return Workflow[]
*/
public function disabled(): array
{
return array_filter(
$this->workflows,
fn(Workflow $workflow) => $workflow->isDisabled()
);
}
/**
* Find workflow by ID
*/
public function findById(int $id): ?Workflow
{
foreach ($this->workflows as $workflow) {
if ($workflow->id === $id) {
return $workflow;
}
}
return null;
}
/**
* Find workflow by name
*/
public function findByName(string $name): ?Workflow
{
foreach ($this->workflows as $workflow) {
if ($workflow->name === $name) {
return $workflow;
}
}
return null;
}
/**
* Check if list is empty
*/
public function isEmpty(): bool
{
return empty($this->workflows);
}
/**
* Get number of workflows
*/
public function count(): int
{
return count($this->workflows);
}
/**
* Get iterator for foreach support
*/
public function getIterator(): ArrayIterator
{
return new ArrayIterator($this->workflows);
}
/**
* Convert to array representation
*/
public function toArray(): array
{
return [
'workflows' => array_map(
fn(Workflow $workflow) => $workflow->toArray(),
$this->workflows
),
'total_count' => $this->count(),
];
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Gitea\ValueObjects;
use App\Framework\Core\ValueObjects\{Timestamp, Duration};
/**
* Workflow Run
*
* Represents a complete workflow run with type-safe properties and business logic.
*/
final readonly class WorkflowRun
{
public function __construct(
public RunId $id,
public string $displayTitle,
public RunStatus $status,
public ?RunConclusion $conclusion,
public Timestamp $startedAt,
public ?Timestamp $completedAt,
public string $headBranch,
public string $headSha,
public int $runNumber,
public string $event,
public ?string $name = null,
) {}
/**
* Create WorkflowRun from Gitea API response
*/
public static function fromApiResponse(array $data): self
{
return new self(
id: RunId::fromApiResponse($data['id']),
displayTitle: $data['display_title'] ?? $data['name'] ?? 'Unknown',
status: RunStatus::from($data['status']),
conclusion: isset($data['conclusion']) && $data['conclusion'] !== null
? RunConclusion::from($data['conclusion'])
: null,
startedAt: Timestamp::fromDateTime(new \DateTimeImmutable($data['started_at'] ?? $data['run_started_at'])),
completedAt: isset($data['completed_at']) && $data['completed_at'] !== null
? Timestamp::fromDateTime(new \DateTimeImmutable($data['completed_at']))
: null,
headBranch: $data['head_branch'] ?? 'unknown',
headSha: $data['head_sha'] ?? '',
runNumber: (int) $data['run_number'],
event: $data['event'],
name: $data['name'] ?? null,
);
}
/**
* Check if the workflow run completed successfully
*/
public function isSuccessful(): bool
{
return $this->status === RunStatus::COMPLETED
&& $this->conclusion?->isSuccessful() === true;
}
/**
* Check if the workflow run failed
*/
public function isFailed(): bool
{
return $this->status === RunStatus::COMPLETED
&& $this->conclusion?->isFailed() === true;
}
/**
* Check if the workflow run is currently executing
*/
public function isRunning(): bool
{
return $this->status->isActive();
}
/**
* Check if the workflow run is completed (any conclusion)
*/
public function isCompleted(): bool
{
return $this->status === RunStatus::COMPLETED;
}
/**
* Check if the workflow run was cancelled
*/
public function wasCancelled(): bool
{
return $this->conclusion?->wasCancelled() === true;
}
/**
* Get the duration of the workflow run
*
* Returns null if the run hasn't completed yet.
*/
public function getDuration(): ?Duration
{
if ($this->completedAt === null) {
return null;
}
return Duration::between($this->startedAt, $this->completedAt);
}
/**
* Get the elapsed time since the run started
*
* Returns duration even for running workflows.
*/
public function getElapsedTime(): Duration
{
$endTime = $this->completedAt ?? Timestamp::now();
return Duration::between($this->startedAt, $endTime);
}
/**
* Get a human-readable status summary
*/
public function getStatusSummary(): string
{
return match (true) {
$this->isSuccessful() => "✅ Successful",
$this->isFailed() => "❌ Failed",
$this->wasCancelled() => "🚫 Cancelled",
$this->isRunning() => "🔄 Running",
default => "{$this->status->value}",
};
}
/**
* Convert to array representation (compatible with API format)
*/
public function toArray(): array
{
return [
'id' => $this->id->value,
'display_title' => $this->displayTitle,
'status' => $this->status->value,
'conclusion' => $this->conclusion?->value,
'started_at' => $this->startedAt->format('Y-m-d H:i:s'),
'completed_at' => $this->completedAt?->format('Y-m-d H:i:s'),
'head_branch' => $this->headBranch,
'head_sha' => $this->headSha,
'run_number' => $this->runNumber,
'event' => $this->event,
'name' => $this->name,
];
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Api\Gitea\ValueObjects;
use IteratorAggregate;
use Countable;
use ArrayIterator;
/**
* Workflow Runs List
*
* Type-safe collection of WorkflowRun objects.
*/
final readonly class WorkflowRunsList implements IteratorAggregate, Countable
{
/** @var WorkflowRun[] */
private array $runs;
/**
* @param WorkflowRun[] $runs
*/
public function __construct(array $runs)
{
// Validate all items are WorkflowRun instances
foreach ($runs as $run) {
if (!$run instanceof WorkflowRun) {
throw new \InvalidArgumentException(
'All items must be WorkflowRun instances'
);
}
}
$this->runs = array_values($runs);
}
/**
* Create from Gitea API response
*/
public static function fromApiResponse(array $data): self
{
$runs = [];
foreach ($data['workflow_runs'] ?? [] as $runData) {
$runs[] = WorkflowRun::fromApiResponse($runData);
}
return new self($runs);
}
/**
* Get all runs
*
* @return WorkflowRun[]
*/
public function all(): array
{
return $this->runs;
}
/**
* Get runs that are currently running
*
* @return WorkflowRun[]
*/
public function running(): array
{
return array_filter(
$this->runs,
fn(WorkflowRun $run) => $run->isRunning()
);
}
/**
* Get runs that completed successfully
*
* @return WorkflowRun[]
*/
public function successful(): array
{
return array_filter(
$this->runs,
fn(WorkflowRun $run) => $run->isSuccessful()
);
}
/**
* Get runs that failed
*
* @return WorkflowRun[]
*/
public function failed(): array
{
return array_filter(
$this->runs,
fn(WorkflowRun $run) => $run->isFailed()
);
}
/**
* Find run by ID
*/
public function findById(RunId $id): ?WorkflowRun
{
foreach ($this->runs as $run) {
if ($run->id->equals($id)) {
return $run;
}
}
return null;
}
/**
* Get the latest run
*/
public function latest(): ?WorkflowRun
{
if (empty($this->runs)) {
return null;
}
return $this->runs[0];
}
/**
* Filter runs by branch
*
* @return WorkflowRun[]
*/
public function forBranch(string $branch): array
{
return array_filter(
$this->runs,
fn(WorkflowRun $run) => $run->headBranch === $branch
);
}
/**
* Get count of successful runs
*/
public function successCount(): int
{
return count($this->successful());
}
/**
* Get count of failed runs
*/
public function failureCount(): int
{
return count($this->failed());
}
/**
* Calculate success rate (0.0 to 1.0)
*/
public function successRate(): float
{
$total = $this->count();
if ($total === 0) {
return 0.0;
}
return $this->successCount() / $total;
}
/**
* Check if list is empty
*/
public function isEmpty(): bool
{
return empty($this->runs);
}
/**
* Get number of runs
*/
public function count(): int
{
return count($this->runs);
}
/**
* Get iterator for foreach support
*/
public function getIterator(): ArrayIterator
{
return new ArrayIterator($this->runs);
}
/**
* Convert to array representation
*/
public function toArray(): array
{
return [
'workflow_runs' => array_map(
fn(WorkflowRun $run) => $run->toArray(),
$this->runs
),
'total_count' => $this->count(),
];
}
}

View File

@@ -47,6 +47,44 @@ $client = new GitHubClient('github_personal_access_token');
$repo = $client->getRepository('username', 'repo-name'); $repo = $client->getRepository('username', 'repo-name');
``` ```
### GiteaClient
Integration mit der Gitea API v1 für Repository-, User- und Issue-Verwaltung:
```php
use App\Infrastructure\Api\Gitea\GiteaClient;
// Über Dependency Injection (empfohlen)
$client = $container->get(GiteaClient::class);
// Oder manuell
use App\Infrastructure\Api\Gitea\GiteaConfig;
use App\Framework\HttpClient\CurlHttpClient;
$config = new GiteaConfig(
baseUrl: 'https://git.michaelschiemer.de',
token: 'your_access_token'
);
$client = new GiteaClient($config, new CurlHttpClient());
// Repository-Operationen
$repos = $client->repositories->list();
$repo = $client->repositories->get('owner', 'repo-name');
// User-Operationen
$currentUser = $client->users->getCurrent();
$user = $client->users->get('username');
// Issue-Operationen
$issues = $client->issues->list('owner', 'repo-name');
$issue = $client->issues->create('owner', 'repo-name', [
'title' => 'New Issue',
'body' => 'Issue description'
]);
```
Siehe [Gitea/README.md](Gitea/README.md) für detaillierte Dokumentation.
## Implementierung eines neuen API-Clients ## Implementierung eines neuen API-Clients
Neue API-Clients können einfach durch Verwendung des `ApiRequestTrait` erstellt werden: Neue API-Clients können einfach durch Verwendung des `ApiRequestTrait` erstellt werden:

View File

@@ -56,7 +56,7 @@ final readonly class CreateComponentStateTable implements Migration
public function getVersion(): MigrationVersion public function getVersion(): MigrationVersion
{ {
return MigrationVersion::fromString('2024_12_20_120000'); return MigrationVersion::fromTimestamp('2024_12_20_120000');
} }
public function getDescription(): string public function getDescription(): string

View File

@@ -0,0 +1,690 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Storage;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Encryption\HmacService;
use App\Framework\Http\Headers;
use App\Framework\Http\Method;
use App\Framework\HttpClient\ClientRequest;
use App\Framework\HttpClient\ClientResponse;
use App\Framework\HttpClient\CurlHttpClient;
use App\Framework\Random\RandomGenerator;
use App\Framework\Storage\Exceptions\StorageConnectionException;
use App\Framework\Storage\Exceptions\StorageOperationException;
/**
* MinIO/S3-compatible client with AWS Signature Version 4
*
* Dependency-free implementation using framework modules:
* - RandomGenerator for cryptographic random bytes
* - HmacService for HMAC-SHA256 signatures
* - Hash Value Objects for payload hashing
* - CurlHttpClient for HTTP requests
*/
final readonly class MinIoClient
{
private const string SERVICE_NAME = 's3';
private const string SIGNATURE_VERSION = 'AWS4-HMAC-SHA256';
private const string ALGORITHM = 'AWS4-HMAC-SHA256';
private string $endpoint;
public function __construct(
string $endpoint,
private string $accessKey,
private string $secretKey,
private string $region = 'us-east-1',
private bool $usePathStyle = true,
private RandomGenerator $randomGenerator,
private HmacService $hmacService,
private CurlHttpClient $httpClient
) {
// Normalize endpoint (remove trailing slash)
$this->endpoint = rtrim($endpoint, '/');
}
/**
* Upload object to bucket
*
* @param array<string, string> $headers Additional headers (e.g., Content-Type)
* @return array{etag: string, size: int, contentType: ?string}
*/
public function putObject(string $bucket, string $key, string $body, array $headers = []): array
{
$url = $this->buildUrl($bucket, $key);
$requestHeaders = $this->buildHeaders($headers);
$payloadHash = Hash::sha256($body)->toString();
$signedRequest = $this->signRequest(
method: Method::PUT,
url: $url,
headers: $requestHeaders,
payloadHash: $payloadHash,
body: $body
);
$response = $this->sendRequest($signedRequest);
if (! $response->status->isSuccess()) {
throw StorageOperationException::for('put', $bucket, $key, "HTTP {$response->status->value}");
}
$etag = $this->extractEtag($response->headers);
$contentType = $requestHeaders->get('Content-Type')?->value() ?? null;
return [
'etag' => $etag,
'size' => strlen($body),
'contentType' => $contentType,
];
}
/**
* Download object from bucket
*/
public function getObject(string $bucket, string $key): string
{
$url = $this->buildUrl($bucket, $key);
$requestHeaders = $this->buildHeaders([]);
$payloadHash = Hash::sha256('')->toString(); // Empty body for GET
$signedRequest = $this->signRequest(
method: Method::GET,
url: $url,
headers: $requestHeaders,
payloadHash: $payloadHash
);
$response = $this->sendRequest($signedRequest);
if ($response->status->value === 404) {
throw StorageOperationException::for('get', $bucket, $key, 'Object not found');
}
if (! $response->status->isSuccess()) {
throw StorageOperationException::for('get', $bucket, $key, "HTTP {$response->status->value}");
}
return $response->body;
}
/**
* Get object metadata (HEAD request)
*
* @return array{etag: ?string, size: ?int, contentType: ?string, lastModified: ?int}
*/
public function headObject(string $bucket, string $key): array
{
$url = $this->buildUrl($bucket, $key);
$requestHeaders = $this->buildHeaders([]);
$payloadHash = Hash::sha256('')->toString();
$signedRequest = $this->signRequest(
method: Method::HEAD,
url: $url,
headers: $requestHeaders,
payloadHash: $payloadHash
);
$response = $this->sendRequest($signedRequest);
if ($response->status->value === 404) {
throw StorageOperationException::for('head', $bucket, $key, 'Object not found');
}
if (! $response->status->isSuccess()) {
throw StorageOperationException::for('head', $bucket, $key, "HTTP {$response->status->value}");
}
$etag = $this->extractEtag($response->headers);
$size = $this->extractSize($response->headers);
$contentType = $this->extractContentType($response->headers);
$lastModified = $this->extractLastModified($response->headers);
return [
'etag' => $etag,
'size' => $size,
'contentType' => $contentType,
'lastModified' => $lastModified,
];
}
/**
* Delete object from bucket
*/
public function deleteObject(string $bucket, string $key): void
{
$url = $this->buildUrl($bucket, $key);
$requestHeaders = $this->buildHeaders([]);
$payloadHash = Hash::sha256('')->toString();
$signedRequest = $this->signRequest(
method: Method::DELETE,
url: $url,
headers: $requestHeaders,
payloadHash: $payloadHash
);
$response = $this->sendRequest($signedRequest);
if ($response->status->value === 404) {
// Object doesn't exist, but that's OK for delete operations
return;
}
if (! $response->status->isSuccess()) {
throw StorageOperationException::for('delete', $bucket, $key, "HTTP {$response->status->value}");
}
}
/**
* Check if object exists
*/
public function objectExists(string $bucket, string $key): bool
{
try {
$this->headObject($bucket, $key);
return true;
} catch (StorageOperationException $e) {
if ($e->getCode() === 404) {
return false;
}
throw $e;
}
}
/**
* Stream object content to destination
*
* Streams HTTP response directly to a writable stream resource.
* Useful for large file downloads without loading entire response into memory.
*
* @param string $bucket Bucket name
* @param string $key Object key
* @param resource $destination Writable stream resource
* @param array<string, mixed> $opts Optional parameters (e.g., 'bufferSize')
* @return int Number of bytes written
* @throws StorageOperationException
*/
public function getObjectToStream(string $bucket, string $key, $destination, array $opts = []): int
{
if (! is_resource($destination)) {
throw StorageOperationException::for('getObjectToStream', $bucket, $key, 'Invalid destination stream');
}
$url = $this->buildUrl($bucket, $key);
$requestHeaders = $this->buildHeaders([]);
$payloadHash = Hash::sha256('')->toString(); // Empty body for GET
$signedRequest = $this->signRequest(
method: Method::GET,
url: $url,
headers: $requestHeaders,
payloadHash: $payloadHash
);
try {
$streamingResponse = $this->httpClient->sendStreaming($signedRequest, $destination);
if ($streamingResponse->status->value === 404) {
throw StorageOperationException::for('getObjectToStream', $bucket, $key, 'Object not found');
}
if (! $streamingResponse->status->isSuccess()) {
throw StorageOperationException::for('getObjectToStream', $bucket, $key, "HTTP {$streamingResponse->status->value}");
}
return $streamingResponse->bytesWritten;
} catch (StorageOperationException $e) {
throw $e;
} catch (\Throwable $e) {
throw StorageConnectionException::for($this->endpoint, $e->getMessage(), $e);
}
}
/**
* Upload object from stream
*
* Streams HTTP request body from a readable stream resource.
* Useful for large file uploads without loading entire file into memory.
*
* @param string $bucket Bucket name
* @param string $key Object key
* @param resource $source Readable stream resource
* @param array<string, mixed> $opts Optional parameters:
* - 'headers' => array<string, string> Additional headers (e.g., Content-Type)
* - 'contentLength' => int Content-Length in bytes (null for chunked transfer)
* @return array{etag: string, size: int, contentType: ?string}
* @throws StorageOperationException
*/
public function putObjectFromStream(string $bucket, string $key, $source, array $opts = []): array
{
if (! is_resource($source)) {
throw StorageOperationException::for('putObjectFromStream', $bucket, $key, 'Invalid source stream');
}
$url = $this->buildUrl($bucket, $key);
$additionalHeaders = $opts['headers'] ?? [];
$requestHeaders = $this->buildHeaders($additionalHeaders);
// Try to get content length if available
$contentLength = $opts['contentLength'] ?? $this->getStreamSize($source);
// For streaming uploads, we use "UNSIGNED-PAYLOAD" for AWS SigV4
// This allows streaming without reading the entire stream to calculate hash
$payloadHash = 'UNSIGNED-PAYLOAD';
$signedRequest = $this->signRequest(
method: Method::PUT,
url: $url,
headers: $requestHeaders,
payloadHash: $payloadHash
);
try {
$response = $this->httpClient->sendStreamingUpload($signedRequest, $source, $contentLength);
if (! $response->status->isSuccess()) {
throw StorageOperationException::for('putObjectFromStream', $bucket, $key, "HTTP {$response->status->value}");
}
$etag = $this->extractEtag($response->headers);
$contentType = $requestHeaders->get('Content-Type')?->value() ?? null;
return [
'etag' => $etag,
'size' => $contentLength ?? 0,
'contentType' => $contentType,
];
} catch (StorageOperationException $e) {
throw $e;
} catch (\Throwable $e) {
throw StorageConnectionException::for($this->endpoint, $e->getMessage(), $e);
}
}
/**
* Get stream size if available
*
* @param resource $stream
* @return int|null Size in bytes, or null if not available
*/
private function getStreamSize($stream): ?int
{
$meta = stream_get_meta_data($stream);
$uri = $meta['uri'] ?? null;
if ($uri !== null && file_exists($uri)) {
$size = filesize($uri);
if ($size !== false) {
return $size;
}
}
// Try to get size from fstat
$stat = @fstat($stream);
if ($stat !== false && isset($stat['size'])) {
return $stat['size'];
}
return null;
}
/**
* Create presigned URL for temporary access
*/
public function createPresignedUrl(string $bucket, string $key, \DateInterval $ttl): string
{
$url = $this->buildUrl($bucket, $key);
$now = time();
$expires = $now + ($ttl->days * 86400) + ($ttl->h * 3600) + ($ttl->i * 60) + $ttl->s;
// Parse URL to add query parameters
$parsedUrl = parse_url($url);
$queryParams = [];
if (isset($parsedUrl['query'])) {
parse_str($parsedUrl['query'], $queryParams);
}
$amzDate = gmdate('Ymd\THis\Z', $now);
$dateStamp = gmdate('Ymd', $now);
$queryParams['X-Amz-Algorithm'] = self::ALGORITHM;
$queryParams['X-Amz-Credential'] = $this->buildCredential($now);
$queryParams['X-Amz-Date'] = $amzDate;
$queryParams['X-Amz-Expires'] = (string) ($expires - $now);
$queryParams['X-Amz-SignedHeaders'] = 'host';
$queryString = http_build_query($queryParams);
$presignedUrl = $parsedUrl['scheme'] . '://' . $parsedUrl['host'];
if (isset($parsedUrl['port'])) {
$presignedUrl .= ':' . $parsedUrl['port'];
}
$presignedUrl .= $parsedUrl['path'] . '?' . $queryString;
// Sign the request
$headers = new Headers();
$payloadHash = Hash::sha256('')->toString();
$signature = $this->calculateSignature(
method: Method::GET,
canonicalUri: $parsedUrl['path'],
canonicalQueryString: $queryString,
canonicalHeaders: $this->buildCanonicalHeaders($headers),
signedHeaders: 'host',
payloadHash: $payloadHash,
timestamp: $now
);
$presignedUrl .= '&X-Amz-Signature=' . $signature;
return $presignedUrl;
}
/**
* Build URL for bucket/key
*/
private function buildUrl(string $bucket, string $key): string
{
$key = ltrim($key, '/');
$encodedKey = $this->encodeKey($key);
if ($this->usePathStyle) {
// Path-style: http://endpoint/bucket/key
return $this->endpoint . '/' . $bucket . '/' . $encodedKey;
}
// Virtual-host-style: http://bucket.endpoint/key
$host = parse_url($this->endpoint, PHP_URL_HOST);
$port = parse_url($this->endpoint, PHP_URL_PORT);
$scheme = parse_url($this->endpoint, PHP_URL_SCHEME) ?? 'http';
$url = $scheme . '://' . $bucket . '.' . $host;
if ($port !== null) {
$url .= ':' . $port;
}
$url .= '/' . $encodedKey;
return $url;
}
/**
* URL-encode key (S3-style encoding)
*/
private function encodeKey(string $key): string
{
// S3 encoding: preserve /, encode everything else
return str_replace('%2F', '/', rawurlencode($key));
}
/**
* Build headers with required S3 headers
*
* @param array<string, string> $additionalHeaders
*/
private function buildHeaders(array $additionalHeaders): Headers
{
$headers = new Headers();
foreach ($additionalHeaders as $name => $value) {
$headers = $headers->with($name, $value);
}
// Add host header
$host = parse_url($this->endpoint, PHP_URL_HOST);
$port = parse_url($this->endpoint, PHP_URL_PORT);
$hostHeader = $port !== null ? "{$host}:{$port}" : $host;
$headers = $headers->with('Host', $hostHeader);
return $headers;
}
/**
* Sign request with AWS Signature Version 4
*/
private function signRequest(
Method $method,
string $url,
Headers $headers,
string $payloadHash,
string $body = ''
): ClientRequest {
$parsedUrl = parse_url($url);
$canonicalUri = $parsedUrl['path'] ?? '/';
$canonicalQueryString = $parsedUrl['query'] ?? '';
// Build canonical headers
$canonicalHeaders = $this->buildCanonicalHeaders($headers);
$signedHeaders = $this->buildSignedHeaders($headers);
// Create canonical request
$canonicalRequest = $this->buildCanonicalRequest(
method: $method->value,
canonicalUri: $canonicalUri,
canonicalQueryString: $canonicalQueryString,
canonicalHeaders: $canonicalHeaders,
signedHeaders: $signedHeaders,
payloadHash: $payloadHash
);
// Create string to sign
$timestamp = time();
$dateStamp = gmdate('Ymd', $timestamp);
$amzDate = gmdate('Ymd\THis\Z', $timestamp);
$credentialScope = "{$dateStamp}/{$this->region}/" . self::SERVICE_NAME . '/aws4_request';
$stringToSign = self::ALGORITHM . "\n"
. $amzDate . "\n"
. $credentialScope . "\n"
. Hash::sha256($canonicalRequest)->toString();
// Calculate signature
$signature = $this->calculateSignature(
method: $method,
canonicalUri: $canonicalUri,
canonicalQueryString: $canonicalQueryString,
canonicalHeaders: $canonicalHeaders,
signedHeaders: $signedHeaders,
payloadHash: $payloadHash,
timestamp: $timestamp
);
// Add Authorization header
$authorization = self::SIGNATURE_VERSION . ' '
. 'Credential=' . $this->accessKey . '/' . $credentialScope . ', '
. 'SignedHeaders=' . $signedHeaders . ', '
. 'Signature=' . $signature;
$signedHeaders = $headers->with('Authorization', $authorization);
$signedHeaders = $signedHeaders->with('X-Amz-Date', $amzDate);
$signedHeaders = $signedHeaders->with('X-Amz-Content-Sha256', $payloadHash);
return new ClientRequest(
method: $method,
url: $url,
headers: $signedHeaders,
body: $body
);
}
/**
* Build canonical headers string
*/
private function buildCanonicalHeaders(Headers $headers): string
{
$canonicalHeaders = [];
$allHeaders = $headers->all();
foreach ($allHeaders as $name => $value) {
$lowerName = strtolower($name);
$canonicalHeaders[$lowerName] = trim($value);
}
// Sort by header name
ksort($canonicalHeaders);
$result = '';
foreach ($canonicalHeaders as $name => $value) {
$result .= $name . ':' . $value . "\n";
}
return $result;
}
/**
* Build signed headers string (semicolon-separated list of header names)
*/
private function buildSignedHeaders(Headers $headers): string
{
$headerNames = [];
foreach (array_keys($headers->all()) as $name) {
$headerNames[] = strtolower($name);
}
sort($headerNames);
return implode(';', $headerNames);
}
/**
* Build canonical request string
*/
private function buildCanonicalRequest(
string $method,
string $canonicalUri,
string $canonicalQueryString,
string $canonicalHeaders,
string $signedHeaders,
string $payloadHash
): string {
return $method . "\n"
. $canonicalUri . "\n"
. $canonicalQueryString . "\n"
. $canonicalHeaders . "\n"
. $signedHeaders . "\n"
. $payloadHash;
}
/**
* Calculate AWS Signature Version 4 signature
*/
private function calculateSignature(
Method $method,
string $canonicalUri,
string $canonicalQueryString,
string $canonicalHeaders,
string $signedHeaders,
string $payloadHash,
int $timestamp
): string {
$dateStamp = gmdate('Ymd', $timestamp);
// Build canonical request
$canonicalRequest = $this->buildCanonicalRequest(
method: $method->value,
canonicalUri: $canonicalUri,
canonicalQueryString: $canonicalQueryString,
canonicalHeaders: $canonicalHeaders,
signedHeaders: $signedHeaders,
payloadHash: $payloadHash
);
// Create string to sign
$credentialScope = "{$dateStamp}/{$this->region}/" . self::SERVICE_NAME . '/aws4_request';
$amzDate = gmdate('Ymd\THis\Z', $timestamp);
$stringToSign = self::ALGORITHM . "\n"
. $amzDate . "\n"
. $credentialScope . "\n"
. Hash::sha256($canonicalRequest)->toString();
// Calculate signing key
$kDate = $this->hmacService->generateHmac($dateStamp, 'AWS4' . $this->secretKey, HashAlgorithm::SHA256);
$kRegion = $this->hmacService->generateHmac($this->region, $kDate->toString(), HashAlgorithm::SHA256);
$kService = $this->hmacService->generateHmac(self::SERVICE_NAME, $kRegion->toString(), HashAlgorithm::SHA256);
$kSigning = $this->hmacService->generateHmac('aws4_request', $kService->toString(), HashAlgorithm::SHA256);
// Calculate signature
$signature = $this->hmacService->generateHmac($stringToSign, $kSigning->toString(), HashAlgorithm::SHA256);
return $signature->toString();
}
/**
* Build credential string for presigned URLs
*/
private function buildCredential(int $timestamp): string
{
$dateStamp = gmdate('Ymd', $timestamp);
$credentialScope = "{$dateStamp}/{$this->region}/" . self::SERVICE_NAME . '/aws4_request';
return $this->accessKey . '/' . $credentialScope;
}
/**
* Send HTTP request and handle errors
*/
private function sendRequest(ClientRequest $request): ClientResponse
{
try {
return $this->httpClient->send($request);
} catch (\Throwable $e) {
throw StorageConnectionException::for($this->endpoint, $e->getMessage(), $e);
}
}
/**
* Extract ETag from response headers
*/
private function extractEtag(Headers $headers): ?string
{
$etag = $headers->get('ETag')?->value();
if ($etag === null) {
return null;
}
// Remove quotes if present
return trim($etag, '"');
}
/**
* Extract content length from response headers
*/
private function extractSize(Headers $headers): ?int
{
$contentLength = $headers->get('Content-Length')?->value();
if ($contentLength === null) {
return null;
}
return (int) $contentLength;
}
/**
* Extract content type from response headers
*/
private function extractContentType(Headers $headers): ?string
{
return $headers->get('Content-Type')?->value();
}
/**
* Extract last modified timestamp from response headers
*/
private function extractLastModified(Headers $headers): ?int
{
$lastModified = $headers->get('Last-Modified')?->value();
if ($lastModified === null) {
return null;
}
$timestamp = strtotime($lastModified);
return $timestamp !== false ? $timestamp : null;
}
}

View File

@@ -0,0 +1,248 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Storage;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Http\CustomMimeType;
use App\Framework\Http\MimeType;
use App\Framework\Http\MimeTypeInterface;
use App\Framework\Storage\ObjectInfo;
use App\Framework\Storage\ObjectStorage;
use App\Framework\Storage\StreamableObjectStorage;
use App\Framework\Storage\Exceptions\ObjectNotFoundException;
use App\Framework\Storage\Exceptions\StorageOperationException;
use App\Framework\Storage\ValueObjects\BucketName;
use App\Framework\Storage\ValueObjects\ObjectKey;
use App\Framework\Storage\ValueObjects\ObjectMetadata;
use App\Framework\Storage\ValueObjects\VersionId;
use DateInterval;
/**
* S3-compatible Object Storage implementation using MinIoClient
*/
final readonly class S3ObjectStorage implements ObjectStorage, StreamableObjectStorage
{
public function __construct(
private MinIoClient $client
) {
}
public function put(string $bucket, string $key, string $body, array $opts = []): ObjectInfo
{
$bucketName = BucketName::fromString($bucket);
$objectKey = ObjectKey::fromString($key);
$headers = $opts['headers'] ?? [];
if (isset($opts['contentType'])) {
$headers['Content-Type'] = $opts['contentType'];
}
$result = $this->client->putObject($bucket, $key, $body, $headers);
// Build Value Objects from result
$etag = $result['etag'] !== null ? Hash::fromString($result['etag'], HashAlgorithm::SHA256) : null;
$size = $result['size'] !== null ? FileSize::fromBytes($result['size']) : null;
$contentType = null;
if ($result['contentType'] !== null) {
$contentType = MimeType::tryFrom($result['contentType']) ?? CustomMimeType::fromString($result['contentType']);
} elseif (isset($opts['contentType'])) {
$contentType = MimeType::tryFrom($opts['contentType']) ?? CustomMimeType::fromString($opts['contentType']);
}
$metadata = ObjectMetadata::fromArray($opts['metadata'] ?? []);
$versionId = isset($opts['versionId']) ? VersionId::fromString($opts['versionId']) : null;
$lastModified = isset($result['lastModified']) && $result['lastModified'] !== null
? Timestamp::fromTimestamp($result['lastModified'])
: null;
return new ObjectInfo(
bucket: $bucketName,
key: $objectKey,
etag: $etag,
size: $size,
contentType: $contentType,
lastModified: $lastModified,
metadata: $metadata,
versionId: $versionId
);
}
public function get(string $bucket, string $key): string
{
try {
return $this->client->getObject($bucket, $key);
} catch (StorageOperationException $e) {
if ($e->getCode() === 404) {
throw ObjectNotFoundException::for($bucket, $key, $e);
}
throw $e;
}
}
public function stream(string $bucket, string $key)
{
// Backward compatibility: returns temporary stream
return $this->openReadStream($bucket, $key);
}
public function getToStream(string $bucket, string $key, $destination, array $opts = []): int
{
if (! is_resource($destination)) {
throw StorageOperationException::for('getToStream', $bucket, $key, 'Invalid destination stream');
}
try {
return $this->client->getObjectToStream($bucket, $key, $destination, $opts);
} catch (StorageOperationException $e) {
if ($e->getCode() === 404) {
throw ObjectNotFoundException::for($bucket, $key, $e);
}
throw $e;
}
}
public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo
{
if (! is_resource($source)) {
throw StorageOperationException::for('putFromStream', $bucket, $key, 'Invalid source stream');
}
$bucketName = BucketName::fromString($bucket);
$objectKey = ObjectKey::fromString($key);
$headers = $opts['headers'] ?? [];
if (isset($opts['contentType'])) {
$headers['Content-Type'] = $opts['contentType'];
}
$clientOpts = [
'headers' => $headers,
'contentLength' => $opts['contentLength'] ?? null,
];
$result = $this->client->putObjectFromStream($bucket, $key, $source, $clientOpts);
// Build Value Objects from result
$etag = $result['etag'] !== null ? Hash::fromString($result['etag'], HashAlgorithm::SHA256) : null;
$size = $result['size'] !== null ? FileSize::fromBytes($result['size']) : null;
$contentType = null;
if ($result['contentType'] !== null) {
$contentType = MimeType::tryFrom($result['contentType']) ?? CustomMimeType::fromString($result['contentType']);
} elseif (isset($opts['contentType'])) {
$contentType = MimeType::tryFrom($opts['contentType']) ?? CustomMimeType::fromString($opts['contentType']);
}
$metadata = ObjectMetadata::fromArray($opts['metadata'] ?? []);
$versionId = isset($opts['versionId']) ? VersionId::fromString($opts['versionId']) : null;
$lastModified = isset($result['lastModified']) && $result['lastModified'] !== null
? Timestamp::fromTimestamp($result['lastModified'])
: null;
return new ObjectInfo(
bucket: $bucketName,
key: $objectKey,
etag: $etag,
size: $size,
contentType: $contentType,
lastModified: $lastModified,
metadata: $metadata,
versionId: $versionId
);
}
public function openReadStream(string $bucket, string $key)
{
// For S3, we create a temporary stream and stream the content into it
// This is necessary because S3 doesn't provide direct stream access
// (would require a custom stream wrapper for true streaming)
$stream = fopen('php://temp', 'r+');
if ($stream === false) {
throw StorageOperationException::for('openReadStream', $bucket, $key, 'Failed to create stream');
}
try {
$this->client->getObjectToStream($bucket, $key, $stream);
rewind($stream);
return $stream;
} catch (StorageOperationException $e) {
fclose($stream);
if ($e->getCode() === 404) {
throw ObjectNotFoundException::for($bucket, $key, $e);
}
throw $e;
}
}
public function head(string $bucket, string $key): ObjectInfo
{
try {
$bucketName = BucketName::fromString($bucket);
$objectKey = ObjectKey::fromString($key);
$result = $this->client->headObject($bucket, $key);
// Build Value Objects from result
$etag = $result['etag'] !== null ? Hash::fromString($result['etag'], HashAlgorithm::SHA256) : null;
$size = $result['size'] !== null ? FileSize::fromBytes($result['size']) : null;
$contentType = null;
if ($result['contentType'] !== null) {
$contentType = MimeType::tryFrom($result['contentType']) ?? CustomMimeType::fromString($result['contentType']);
}
$lastModified = $result['lastModified'] !== null
? Timestamp::fromTimestamp($result['lastModified'])
: null;
return new ObjectInfo(
bucket: $bucketName,
key: $objectKey,
etag: $etag,
size: $size,
contentType: $contentType,
lastModified: $lastModified,
metadata: ObjectMetadata::empty(),
versionId: null
);
} catch (StorageOperationException $e) {
if ($e->getCode() === 404) {
throw ObjectNotFoundException::for($bucket, $key, $e);
}
throw $e;
}
}
public function delete(string $bucket, string $key): void
{
$this->client->deleteObject($bucket, $key);
}
public function exists(string $bucket, string $key): bool
{
return $this->client->objectExists($bucket, $key);
}
public function url(string $bucket, string $key): ?string
{
// S3 public URLs are only available if bucket is public
// For now, return null (can be extended later)
return null;
}
public function temporaryUrl(string $bucket, string $key, DateInterval $ttl, array $opts = []): string
{
return $this->client->createPresignedUrl($bucket, $key, $ttl);
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
use App\Framework\Console\ConsoleCommand;
use App\Framework\Core\ParameterTypeValidator;
use App\Framework\Examples\MultiPurposeAction;
use App\Framework\Mcp\McpTool;
use App\Framework\Reflection\WrappedReflectionMethod;
use App\Framework\Reflection\Cache\MethodCache;
use App\Framework\Core\ValueObjects\ClassName;
test('multi-purpose method has multiple attributes', function () {
$className = ClassName::create(MultiPurposeAction::class);
$methodName = 'listUsers';
// Create a mock method cache (simplified for testing)
$reflectionMethod = new \ReflectionMethod(MultiPurposeAction::class, $methodName);
$attributes = $reflectionMethod->getAttributes();
$attributeNames = array_map(fn($attr) => $attr->getName(), $attributes);
expect($attributeNames)->toContain(McpTool::class);
expect($attributeNames)->toContain(ConsoleCommand::class);
// Note: Route attribute checking would need Route class import
});
test('parameter type validator recognizes builtin types', function () {
$validator = new ParameterTypeValidator();
// Use reflection from actual method to test builtin types
$reflectionMethod = new \ReflectionMethod(MultiPurposeAction::class, 'listUsers');
$parameters = $reflectionMethod->getParameters();
// Test string parameter
$stringParam = $parameters[0]; // $status: string
expect($validator->isBuiltinType($stringParam->getType()))->toBeTrue();
// Test int parameter
$intParam = $parameters[1]; // $limit: int
expect($validator->isBuiltinType($intParam->getType()))->toBeTrue();
// Test bool parameter
$boolParam = $parameters[2]; // $includeDetails: bool
expect($validator->isBuiltinType($boolParam->getType()))->toBeTrue();
});
test('parameter type validator rejects non-builtin types', function () {
$validator = new ParameterTypeValidator();
// Use reflection from a method that has non-builtin types (if available)
// For now, test with a class that doesn't exist in our test
$reflectionClass = new \ReflectionClass(MultiPurposeAction::class);
// All methods in MultiPurposeAction should have only builtin types
// So we'll test with a known non-builtin type string
$classType = new \ReflectionClass(\stdClass::class);
$method = $classType->getMethod('__construct');
$parameters = $method->getParameters();
// If there are parameters, check they're not builtin (if they're object types)
if (count($parameters) > 0 && $parameters[0]->getType() instanceof \ReflectionNamedType) {
$typeName = $parameters[0]->getType()->getName();
if (! in_array($typeName, ['string', 'int', 'float', 'bool', 'array', 'mixed'], true)) {
expect($validator->isBuiltinType($parameters[0]->getType()))->toBeFalse();
}
}
});
test('multi-purpose method returns ActionResult', function () {
$action = new MultiPurposeAction();
$result = $action->listUsers('active', 10, false);
expect($result)->toBeInstanceOf(\App\Framework\Router\ActionResult::class);
expect($result)->toBeInstanceOf(\App\Framework\Router\Result\JsonResult::class);
});
test('ConsoleResult implements ActionResult', function () {
$textResult = new \App\Framework\Console\Result\TextResult('Test message');
expect($textResult)->toBeInstanceOf(\App\Framework\Router\ActionResult::class);
expect($textResult)->toBeInstanceOf(\App\Framework\Console\Result\ConsoleResult::class);
});
test('ToolResult implements ActionResult', function () {
$toolResult = \App\Framework\Mcp\Core\ValueObjects\ToolResult::success(['data' => 'test']);
expect($toolResult)->toBeInstanceOf(\App\Framework\Router\ActionResult::class);
expect($toolResult)->toBeInstanceOf(\App\Framework\Mcp\Core\ValueObjects\ToolResult::class);
});

View File

@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Infrastructure\Api\Gitea\GiteaClient;
use App\Infrastructure\Api\Gitea\GiteaConfig;
use App\Framework\HttpClient\CurlHttpClient;
// Load environment variables manually
$envFile = __DIR__ . '/../../.env';
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos(trim($line), '#') === 0) {
continue;
}
putenv($line);
}
}
// Initialize GiteaClient
$config = new GiteaConfig(
baseUrl: getenv('GITEA_URL') ?: '',
token: getenv('GITEA_TOKEN') ?: null,
username: getenv('GITEA_USERNAME') ?: null,
password: getenv('GITEA_PASSWORD') ?: null,
timeout: (float) (getenv('GITEA_TIMEOUT') ?: 30.0)
);
$httpClient = new CurlHttpClient();
$giteaClient = new GiteaClient($config, $httpClient);
echo "\n╔═══════════════════════════════════════════════════════════════════╗\n";
echo "║ Gitea Actions API - Value Objects Demo ║\n";
echo "╚═══════════════════════════════════════════════════════════════════╝\n\n";
$owner = 'michael';
$repo = 'michaelschiemer';
try {
// =====================================================================
// Test 1: List Workflows (Typed)
// =====================================================================
echo "📋 Test 1: Listing Workflows (Type-Safe)\n";
echo str_repeat('─', 60) . "\n";
$workflows = $giteaClient->actions->listWorkflowsTyped($owner, $repo);
echo " Total workflows: {$workflows->count()}\n";
echo " Active workflows: " . count($workflows->active()) . "\n";
echo " Disabled workflows: " . count($workflows->disabled()) . "\n\n";
foreach ($workflows as $workflow) {
$statusIcon = $workflow->isActive() ? '✅' : '❌';
echo " {$statusIcon} Workflow: {$workflow->name}\n";
echo " ID: {$workflow->id}\n";
echo " Path: {$workflow->path}\n";
echo " File: {$workflow->getFileName()}\n";
echo " State: {$workflow->state}\n";
echo "\n";
}
// =====================================================================
// Test 2: List Workflow Runs (Typed)
// =====================================================================
echo "🏃 Test 2: Listing Recent Workflow Runs (Type-Safe)\n";
echo str_repeat('─', 60) . "\n";
$runs = $giteaClient->actions->listRunsTyped($owner, $repo, ['limit' => 10]);
echo " Total runs: {$runs->count()}\n";
echo " Running: " . count($runs->running()) . "\n";
$successRate = $runs->successRate() * 100;
echo " Successful: {$runs->successCount()} ({$successRate}%)\n";
echo " Failed: {$runs->failureCount()}\n\n";
foreach ($runs as $run) {
echo " {$run->getStatusSummary()} Run #{$run->id}\n";
echo " Title: {$run->displayTitle}\n";
echo " Status: {$run->status->value}\n";
echo " Branch: {$run->headBranch}\n";
echo " Started: {$run->startedAt->format('Y-m-d H:i:s')}\n";
if ($run->isCompleted() && $run->getDuration() !== null) {
$duration = $run->getDuration();
echo " Duration: {$duration->toSeconds()}s\n";
} elseif ($run->isRunning()) {
$elapsed = $run->getElapsedTime();
echo " Elapsed: {$elapsed->toSeconds()}s (still running)\n";
}
echo "\n";
}
// =====================================================================
// Test 3: Get Specific Run Details (Typed)
// =====================================================================
if (!$runs->isEmpty()) {
echo "🔍 Test 3: Detailed Run Information (Type-Safe)\n";
echo str_repeat('─', 60) . "\n";
$latestRun = $runs->latest();
echo " Run ID: {$latestRun->id}\n";
echo " Title: {$latestRun->displayTitle}\n";
echo " Status: {$latestRun->getStatusSummary()}\n";
echo " Branch: {$latestRun->headBranch}\n";
echo " Commit: " . substr($latestRun->headSha, 0, 8) . "\n";
echo " Triggered by: {$latestRun->event}\n";
echo " Run Number: {$latestRun->runNumber}\n";
echo "\n";
echo " Business Logic Methods:\n";
echo " - isSuccessful(): " . ($latestRun->isSuccessful() ? 'true' : 'false') . "\n";
echo " - isFailed(): " . ($latestRun->isFailed() ? 'true' : 'false') . "\n";
echo " - isRunning(): " . ($latestRun->isRunning() ? 'true' : 'false') . "\n";
echo " - wasCancelled(): " . ($latestRun->wasCancelled() ? 'true' : 'false') . "\n";
if ($latestRun->getDuration() !== null) {
$duration = $latestRun->getDuration();
echo " - getDuration(): {$duration->toSeconds()}s\n";
}
echo "\n";
}
// =====================================================================
// Test 4: Collection Methods Demo
// =====================================================================
echo "📊 Test 4: Collection Methods Demo\n";
echo str_repeat('─', 60) . "\n";
// Filter by branch
$mainBranchRuns = $runs->forBranch('main');
echo " Runs on 'main' branch: " . count($mainBranchRuns) . "\n";
// Find by ID
if (!$runs->isEmpty()) {
$firstRunId = $runs->latest()->id;
$foundRun = $runs->findById($firstRunId);
echo " Found run by ID: " . ($foundRun !== null ? 'Yes' : 'No') . "\n";
}
echo "\n";
// =====================================================================
// Test 5: Backward Compatibility Check
// =====================================================================
echo "🔄 Test 5: Backward Compatibility (Raw Arrays Still Work)\n";
echo str_repeat('─', 60) . "\n";
// Old API still works
$rawWorkflows = $giteaClient->actions->listWorkflows($owner, $repo);
echo " Raw array method works: Yes\n";
echo " Raw workflows count: " . count($rawWorkflows['workflows'] ?? []) . "\n";
$rawRuns = $giteaClient->actions->listRuns($owner, $repo, ['limit' => 5]);
echo " Raw runs method works: Yes\n";
echo " Raw runs count: " . count($rawRuns['workflow_runs'] ?? []) . "\n";
echo "\n";
echo "✅ All Tests SUCCESSFUL!\n\n";
// =====================================================================
// Insights
// =====================================================================
echo "╔═══════════════════════════════════════════════════════════════════╗\n";
echo "║ ★ Insights ─────────────────────────────────────────────────────║\n";
echo "╠═══════════════════════════════════════════════════════════════════╣\n";
echo "║ ║\n";
echo "║ Value Objects bieten mehrere Vorteile: ║\n";
echo "║ ║\n";
echo "║ 1. Type Safety - IDE erkennt Fehler zur Compile-Zeit ║\n";
echo "\$run->id (RunId) statt \$run['id'] (mixed) ║\n";
echo "║ ║\n";
echo "║ 2. Business Logic - Methoden wie isSuccessful(), getDuration() ║\n";
echo "║ direkt am Objekt statt externe Helper-Funktionen ║\n";
echo "║ ║\n";
echo "║ 3. Collection Methods - Filtern, Suchen, Aggregieren ║\n";
echo "\$runs->successful(), \$runs->successRate() etc. ║\n";
echo "║ ║\n";
echo "║ 4. API Evolution - Änderungen in Gitea API isoliert ║\n";
echo "║ fromApiResponse() mappt API-Änderungen transparent ║\n";
echo "║ ║\n";
echo "║ 5. Backward Compatible - Raw arrays funktionieren weiterhin ║\n";
echo "║ listWorkflows() (array) + listWorkflowsTyped() (VOs) ║\n";
echo "║ ║\n";
echo "╚═══════════════════════════════════════════════════════════════════╝\n\n";
} catch (\Exception $e) {
echo "\n❌ Error: " . $e->getMessage() . "\n";
echo "File: " . $e->getFile() . "\n";
echo "Line: " . $e->getLine() . "\n";
exit(1);
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Infrastructure\Api\Gitea\GiteaClient;
use App\Infrastructure\Api\Gitea\GiteaConfig;
use App\Framework\HttpClient\CurlHttpClient;
// Load environment variables manually
$envFile = __DIR__ . '/../../.env';
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos(trim($line), '#') === 0) {
continue;
}
putenv($line);
}
}
// Initialize GiteaClient
$config = new GiteaConfig(
baseUrl: getenv('GITEA_URL') ?: '',
token: getenv('GITEA_TOKEN') ?: null,
username: getenv('GITEA_USERNAME') ?: null,
password: getenv('GITEA_PASSWORD') ?: null,
timeout: (float) (getenv('GITEA_TIMEOUT') ?: 30.0)
);
$httpClient = new CurlHttpClient();
$giteaClient = new GiteaClient($config, $httpClient);
echo "\n=== Testing Gitea Actions/Workflows API ===\n\n";
$owner = 'michael';
$repo = 'michaelschiemer';
try {
// Test 1: List Workflows
echo "1. Listing Workflows for {$owner}/{$repo}:\n";
echo str_repeat('-', 60) . "\n";
$workflows = $giteaClient->actions->listWorkflows($owner, $repo);
if (isset($workflows['workflows']) && !empty($workflows['workflows'])) {
foreach ($workflows['workflows'] as $workflow) {
echo " - Workflow: {$workflow['name']}\n";
echo " ID: {$workflow['id']}\n";
echo " Path: {$workflow['path']}\n";
echo " State: {$workflow['state']}\n";
echo "\n";
}
} else {
echo " No workflows found.\n\n";
}
// Test 2: List Workflow Runs (last 10)
echo "2. Listing Recent Workflow Runs (last 10):\n";
echo str_repeat('-', 60) . "\n";
$runs = $giteaClient->actions->listRuns($owner, $repo, [
'limit' => 10
]);
if (isset($runs['workflow_runs']) && !empty($runs['workflow_runs'])) {
foreach ($runs['workflow_runs'] as $run) {
echo " - Run #{$run['id']}\n";
echo " Title: {$run['display_title']}\n";
echo " Status: {$run['status']}\n";
echo " Conclusion: " . ($run['conclusion'] ?? 'N/A') . "\n";
echo " Started: {$run['started_at']}\n";
echo " Branch: {$run['head_branch']}\n";
echo "\n";
}
// Test 3: Get detailed info about latest run
$latestRun = $runs['workflow_runs'][0];
echo "3. Detailed Info for Latest Run (#{$latestRun['id']}):\n";
echo str_repeat('-', 60) . "\n";
$runDetails = $giteaClient->actions->getRun($owner, $repo, $latestRun['id']);
echo " Workflow: {$runDetails['name']}\n";
echo " Status: {$runDetails['status']}\n";
echo " Conclusion: " . ($runDetails['conclusion'] ?? 'N/A') . "\n";
echo " Triggered by: {$runDetails['event']}\n";
echo " Branch: {$runDetails['head_branch']}\n";
echo " Commit: {$runDetails['head_sha']}\n";
echo " Run Number: {$runDetails['run_number']}\n";
echo " Started: {$runDetails['run_started_at']}\n";
if (isset($runDetails['jobs'])) {
echo "\n Jobs:\n";
foreach ($runDetails['jobs'] as $job) {
echo " - {$job['name']}: {$job['status']}";
if (isset($job['conclusion'])) {
echo " ({$job['conclusion']})";
}
echo "\n";
}
}
} else {
echo " No workflow runs found.\n";
}
echo "\n✅ Actions/Workflows API Test SUCCESSFUL!\n";
} catch (\Exception $e) {
echo "\n❌ Error: " . $e->getMessage() . "\n";
echo "File: " . $e->getFile() . "\n";
echo "Line: " . $e->getLine() . "\n";
exit(1);
}