diff --git a/changelog.md b/changelog.md index e165f22..6a9d6f3 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,11 @@ # Changelog SPIN Framework Changelog +## 0.0.35 +- Unittests updated to support PHP 8.4 and PHPUnit 12 +- Docblocks refactores in many places +- Config + ## 0.0.34 - Bugfix on PDO not correctly parsing default options - Add Redit and cache adapters unit tests with CI sidecar container @@ -18,7 +23,7 @@ SPIN Framework Changelog - Fix MySQL PDO driver compatibility (Removal of overridden connect base method) ## 0.0.30 -- Remove deprecated error constant E_STRICT usage +- Remove deprecated error constant E_STRICT usage - Remove AbstractBaseDaos ## 0.0.29 diff --git a/composer.json b/composer.json index 63efde4..e6ab294 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "A super lightweight PHP UI/REST Framework", "version": "0.0.34", "keywords": [ - "php7", + "php8", "php-framework", "framework", "api", @@ -61,5 +61,9 @@ }, "require-dev": { "phpunit/phpunit": "^10.5 || ^11.0 || ^12.0" + }, + "config": { + "optimize-autoloader": true } } + diff --git a/doc/Configuration.md b/doc/Configuration.md new file mode 100644 index 0000000..1ce79c1 --- /dev/null +++ b/doc/Configuration.md @@ -0,0 +1,349 @@ +# Configuration + +SPIN Framework uses a JSON-based configuration system that's loaded at runtime and provides easy access through helper functions. + +## Configuration Structure + +SPIN applications use JSON configuration files organized by environment (e.g., `config-dev.json`, `config-prod.json`). The configuration is structured hierarchically and supports environment variables. + +### Basic Configuration File Structure + +```json +{ + "application": { + "global": { + "maintenance": false, + "message": "We are in maintenance mode, back shortly", + "timezone": "Europe/Stockholm" + }, + "secret": "${application-secret}" + }, + "session": { + "cookie": "SID", + "timeout": 3600, + "refresh": 600, + "driver": "apcu", + "apcu": { + "option": "value" + } + }, + "logger": { + "level": "notice", + "driver": "php", + "drivers": { + "php": { + "line_format": "[%channel%] [%level_name%] %message% %context%", + "line_datetime": "Y-m-d H:i:s.v e" + }, + "file": { + "file_path": "storage/log", + "file_format": "Y-m-d", + "line_format": "[%datetime%] [%channel%] [%level_name%] %message% %context%", + "line_datetime": "Y-m-d H:i:s.v e" + } + } + }, + "templates": { + "extension": "html", + "errors": "/Views/Errors", + "pages": "/Views/Pages" + }, + "caches": { + "local.apcu": { + "adapter": "APCu", + "class": "\\Spin\\Cache\\Adapters\\Apcu", + "options": {} + }, + "remote.redis": { + "adapter": "Redis", + "class": "\\Spin\\Cache\\Adapters\\Redis", + "options": { + "host": "172.20.0.1", + "port": 6379 + } + } + }, + "factories": { + "http": { + "serverRequest": { + "class": "\\Spin\\Factories\\Http\\ServerRequestFactory", + "options": {} + }, + "request": { + "class": "\\Spin\\Factories\\Http\\RequestFactory", + "options": {} + }, + "response": { + "class": "\\Spin\\Factories\\Http\\ResponseFactory", + "options": {} + }, + "stream": { + "class": "\\Spin\\Factories\\Http\\StreamFactory", + "options": {} + }, + "uploadedFile": { + "class": "\\Spin\\Factories\\Http\\UploadedFileFactory", + "options": {} + }, + "uri": { + "class": "\\Spin\\Factories\\Http\\UriFactory", + "options": {} + } + }, + "container": { + "class": "\\Spin\\Factories\\ContainerFactory", + "options": { + "autowire": true + } + }, + "event": { + "class": "\\Spin\\Factories\\EventFactory", + "options": {} + } + }, + "hooks": [ + { + "OnBeforeRequest": [ + "\\App\\Hooks\\OnBeforeRequest" + ], + "OnAfterRequest": [ + "\\App\\Hooks\\OnAfterRequest" + ] + } + ], + "connections": { + "example_mysql": { + "type": "Pdo", + "driver": "mysql", + "schema": "", + "host": "localhost", + "port": 3306, + "username": "root", + "password": "*****", + "charset": "UTF8", + "options": [ + { + "ATTR_PERSISTENT": true + }, + { + "ATTR_ERRMODE": "ERRMODE_EXCEPTION" + }, + { + "ATTR_AUTOCOMMIT": false + } + ] + } + } +} +``` + +## Accessing Configuration + +SPIN provides a `config()` helper function to access configuration values using dot notation: + +```php +// Access nested configuration values +$maintenance = config('application.global.maintenance'); +$timezone = config('application.global.timezone'); +$sessionTimeout = config('session.timeout'); + +// Access with default values +$logLevel = config('logger.level', 'info'); +$dbHost = config('connections.example_mysql.host', 'localhost'); +``` + +## Environment Variables + +SPIN supports environment variables in configuration using `${VARIABLE_NAME}` syntax: + +```json +{ + "application": { + "secret": "${APPLICATION_SECRET}", + "database": { + "password": "${DB_PASSWORD}" + } + } +} +``` + +## Configuration Sections + +### Application Configuration + +```json +{ + "application": { + "global": { + "maintenance": false, + "message": "We are in maintenance mode, back shortly", + "timezone": "Europe/Stockholm" + }, + "secret": "${application-secret}" + } +} +``` + +### Session Configuration + +```json +{ + "session": { + "cookie": "SID", + "timeout": 3600, + "refresh": 600, + "driver": "apcu", + "apcu": { + "option": "value" + } + } +} +``` + +### Logger Configuration + +```json +{ + "logger": { + "level": "notice", + "driver": "php", + "drivers": { + "php": { + "line_format": "[%channel%] [%level_name%] %message% %context%", + "line_datetime": "Y-m-d H:i:s.v e" + }, + "file": { + "file_path": "storage/log", + "file_format": "Y-m-d", + "line_format": "[%datetime%] [%channel%] [%level_name%] %message% %context%", + "line_datetime": "Y-m-d H:i:s.v e" + } + } + } +} +``` + +### Cache Configuration + +```json +{ + "caches": { + "local.apcu": { + "adapter": "APCu", + "class": "\\Spin\\Cache\\Adapters\\Apcu", + "options": {} + }, + "remote.redis": { + "adapter": "Redis", + "class": "\\Spin\\Cache\\Adapters\\Redis", + "options": { + "host": "172.20.0.1", + "port": 6379 + } + } + } +} +``` + +### Database Connections + +```json +{ + "connections": { + "example_mysql": { + "type": "Pdo", + "driver": "mysql", + "schema": "", + "host": "localhost", + "port": 3306, + "username": "root", + "password": "*****", + "charset": "UTF8", + "options": [ + { + "ATTR_PERSISTENT": true + }, + { + "ATTR_ERRMODE": "ERRMODE_EXCEPTION" + }, + { + "ATTR_AUTOCOMMIT": false + } + ] + }, + "example_sqlite": { + "type": "Pdo", + "driver": "SqlLite", + "filename": "storage\\database\\db.sqlite" + } + } +} +``` + +### Factory Configuration + +```json +{ + "factories": { + "http": { + "serverRequest": { + "class": "\\Spin\\Factories\\Http\\ServerRequestFactory", + "options": {} + }, + "response": { + "class": "\\Spin\\Factories\\Http\\ResponseFactory", + "options": {} + } + }, + "container": { + "class": "\\Spin\\Factories\\ContainerFactory", + "options": { + "autowire": true + } + } + } +} +``` + +### Hooks Configuration + +```json +{ + "hooks": [ + { + "OnBeforeRequest": [ + "\\App\\Hooks\\OnBeforeRequest" + ], + "OnAfterRequest": [ + "\\App\\Hooks\\OnAfterRequest" + ] + } + ] +} +``` + +## Configuration Best Practices + +1. **Environment Separation**: Use different configuration files for different environments (dev, staging, prod) +2. **Sensitive Data**: Store sensitive information like passwords and secrets in environment variables +3. **Validation**: Validate configuration values at startup +4. **Defaults**: Provide sensible default values for optional configuration +5. **Documentation**: Document all configuration options and their expected values + +## Configuration Validation + +SPIN automatically validates configuration when the application starts. Missing required configuration will cause the application to fail to start. + +## Dynamic Configuration + +While SPIN primarily uses static JSON configuration, you can also set configuration values programmatically: + +```php +// Set configuration values at runtime +config('application.global.maintenance', true); +config('session.timeout', 7200); +``` + +## Configuration Caching + +SPIN caches configuration in memory for performance. Changes to configuration files require an application restart to take effect. diff --git a/doc/Middleware.md b/doc/Middleware.md new file mode 100644 index 0000000..eb3a4e6 --- /dev/null +++ b/doc/Middleware.md @@ -0,0 +1,510 @@ +# Middleware + +SPIN Framework uses a middleware system that allows you to intercept and modify HTTP requests and responses. Middleware extends the `Spin\Core\Middleware` class and provides a clean way to handle cross-cutting concerns like authentication, logging, and CORS. + +## Middleware Structure + +SPIN middleware classes extend `Spin\Core\Middleware` and implement two main methods: + +```php +info('User authenticated', ['userId' => $userId]); +logger()->error('Authentication failed', ['error' => $error]); +logger()->critical('Critical error', ['trace' => $trace]); +``` + +## Middleware Examples + +### Authentication Middleware + +```php +secret = config('application.secret'); + return true; + } + + /** + * Handle authentication + */ + public function handle(array $args): bool + { + $authenticated = false; + $type = 'token'; + $token = config('integrations.core.token'); + $authenticated = $this->authToken($token); + + # Failed authentication + if (!$authenticated && getResponse()->getStatusCode() < 400) { + response('', 401, [ + 'WWW-Authenticate' => $type . ' realm="' . + (getRequest()->getHeader('Host')[0] ?? '') . '"' + ]); + } + + return $authenticated; + } + + /** + * Token authentication + */ + protected function authToken(string $token): bool + { + $authenticated = false; + $tokens = config('tokens') ?? []; + $authenticated = array_key_exists($token, $tokens); + return $authenticated; + } + + /** + * Bearer authentication (JWT) + */ + protected function authBearer(string $token): bool + { + $authenticated = false; + + try { + # Verify the Token and decode the payload + $payload = JWT::decode($token, $this->secret, ['HS256']); + + if (!is_null($payload)) { + # Store the Payload in the Dependency Container + container('jwt:payload', $payload); + $authenticated = true; + } + } catch (\Exception $e) { + logger()->critical($e->getMessage(), [ + 'rid' => container('requestId'), + 'msg' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + } + + return $authenticated; + } +} +``` + +### Session Middleware + +```php +getMethod() === 'OPTIONS') { + $response = $response->withStatus(200); + $response = $response->withHeader('Access-Control-Allow-Origin', '*'); + $response = $response->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + $response = $response->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + return true; + } + + # Add CORS headers to all responses + $response = $response->withHeader('Access-Control-Allow-Origin', '*'); + $response = $response->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + $response = $response->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + return true; + } +} +``` + +### Request ID Middleware + +```php +withHeader('X-Request-ID', $requestId); + + return true; + } +} +``` + +### Response Logging Middleware + +```php +info('Response completed', [ + 'rid' => $requestId, + 'method' => $request->getMethod(), + 'path' => $request->getUri()->getPath(), + 'status' => $response->getStatusCode(), + 'size' => $response->getBody()->getSize() + ]); + + return true; + } +} +``` + +## Middleware Registration + +Middleware is registered in the route configuration file (`routes-dev.json`): + +### Common Middleware + +Applied to all routes: + +```json +{ + "common": { + "before": [ + "\\App\\Middlewares\\RequestIdBeforeMiddleware" + ], + "after": [ + "\\App\\Middlewares\\ResponseTimeAfterMiddleware", + "\\App\\Middlewares\\ResponseLogAfterMiddleware" + ] + } +} +``` + +### Group Middleware + +Applied to specific route groups: + +```json +{ + "name": "Private", + "prefix": "/api/v1", + "before": [ + "\\App\\Middlewares\\AuthHttpBeforeMiddleware" + ], + "routes": [ + { "methods": ["GET"], "path": "/profile", "handler": "\\App\\Controllers\\ProfileController" } + ] +} +``` + +### Route-Specific Middleware + +Applied to individual routes: + +```json +{ + "methods": ["POST"], + "path": "/users", + "handler": "\\App\\Controllers\\UserController", + "middleware": [ + "\\App\\Middlewares\\ValidationMiddleware" + ] +} +``` + +## Middleware Order + +Middleware execution order is important: + +1. **Common Before** - Applied to all routes +2. **Group Before** - Applied to route groups +3. **Route Before** - Applied to specific routes +4. **Controller** - Route handler execution +5. **Route After** - Applied to specific routes +6. **Group After** - Applied to route groups +7. **Common After** - Applied to all routes + +## Middleware Best Practices + +1. **Single Responsibility**: Each middleware should handle one concern +2. **Performance**: Keep middleware lightweight and efficient +3. **Error Handling**: Always handle exceptions gracefully +4. **Logging**: Log important events and errors +5. **Configuration**: Use configuration for customizable behavior +6. **Testing**: Write unit tests for middleware logic +7. **Documentation**: Document middleware purpose and behavior + +## Conditional Middleware + +Middleware can be conditionally applied based on request properties: + +```php +public function handle(array $args): bool +{ + $request = getRequest(); + + # Only apply to API routes + if (!str_starts_with($request->getUri()->getPath(), '/api/')) { + return true; + } + + # Apply middleware logic + return $this->processApiRequest($args); +} +``` + +## Middleware Parameters + +Middleware can receive parameters from the route configuration: + +```json +{ + "before": [ + "\\App\\Middlewares\\RateLimitMiddleware:100,60" + ] +} +``` + +```php +public function initialize(array $args): bool +{ + # Parse parameters (e.g., "100,60" -> max 100 requests per 60 seconds) + $params = explode(',', $args['params'] ?? '100,60'); + $this->maxRequests = (int)($params[0] ?? 100); + $this->timeWindow = (int)($params[1] ?? 60); + + return true; +} +``` + +## Error Handling in Middleware + +Always handle errors gracefully in middleware: + +```php +public function handle(array $args): bool +{ + try { + // Middleware logic + return $this->processRequest($args); + } catch (\Exception $e) { + logger()->error('Middleware error: ' . $e->getMessage(), [ + 'rid' => container('requestId'), + 'middleware' => static::class, + 'error' => $e->getMessage() + ]); + + // Return false to block the request or true to continue + return false; + } +} +``` + +## Testing Middleware + +Test middleware in isolation: + +```php + 'valid_token']; + + $result = $middleware->handle($args); + + $this->assertTrue($result); + } + + public function testFailedAuthentication() + { + $middleware = new AuthHttpBeforeMiddleware(); + $args = ['token' => 'invalid_token']; + + $result = $middleware->handle($args); + + $this->assertFalse($result); + } +} +``` + +This middleware system provides a powerful and flexible way to handle cross-cutting concerns in your SPIN application while maintaining clean separation of concerns. diff --git a/doc/Routing.md b/doc/Routing.md new file mode 100644 index 0000000..c5cf6d4 --- /dev/null +++ b/doc/Routing.md @@ -0,0 +1,430 @@ +# Routing + +SPIN Framework uses a JSON-based routing system that defines routes, middleware, and controllers in a structured configuration file. Routes are organized into groups with shared middleware and prefixes. + +## Route Configuration + +SPIN applications define routes in JSON configuration files (e.g., `routes-dev.json`) that specify the routing structure, middleware, and controller mappings. + +### Basic Route Configuration Structure + +```json +{ + "common": { + "before": [], + "after": [ + "\\App\\Middlewares\\RequestIdAfterMiddleware", + "\\App\\Middlewares\\ResponseTimeAfterMiddleware", + "\\App\\Middlewares\\ResponseLogAfterMiddleware" + ] + }, + "groups": [ + { + "name": "unversioned api endpoints", + "notes": "", + "prefix": "/api", + "before": [], + "routes": [ + { "methods":["GET"], "path":"/health", "handler":"\\App\\Controllers\\Api\\HealthController" }, + { "methods":["GET"], "path":"/status", "handler":"\\App\\Controllers\\Api\\StatusController" }, + { "methods":["GET"], "path":"/info", "handler":"\\App\\Controllers\\Api\\InfoController" } + ], + "after": [] + }, + { + "name":"Public", + "notes": "Public endpoints", + "prefix": "/api/v1", + "before": [], + "routes": [], + "after": [] + }, + { + "name":"Private", + "notes": "Private endpoints", + "prefix": "/api/v1", + "before": [ + "\\App\\Middlewares\\AuthHttpBeforeMiddleware" + ], + "routes": [], + "after": [] + }, + { + "name":"Default", + "notes": "Default route if nothing else matches", + "prefix": "", + "before": [ + "\\App\\Middlewares\\SessionBeforeMiddleware" + ], + "routes": [ + { "methods":[], "path":"/", "handler":"\\App\\Controllers\\IndexController" } + ], + "after": [] + } + ], + "errors": { + "4xx": "\\App\\Controllers\\Error4xxController", + "5xx": "\\App\\Controllers\\Error5xxController" + } +} +``` + +## Route Groups + +Route groups allow you to organize related routes with shared middleware and prefixes. + +### Group Structure + +```json +{ + "name": "Group Name", + "notes": "Description of the group", + "prefix": "/api/v1", + "before": ["\\App\\Middlewares\\AuthMiddleware"], + "routes": [ + // Route definitions + ], + "after": ["\\App\\Middlewares\\LoggingMiddleware"] +} +``` + +### Group Properties + +- **name**: Descriptive name for the route group +- **notes**: Additional information about the group +- **prefix**: URL prefix applied to all routes in the group +- **before**: Middleware executed before route handling +- **routes**: Array of route definitions +- **after**: Middleware executed after route handling + +## Route Definitions + +Individual routes define the HTTP methods, path, and controller handler. + +### Route Structure + +```json +{ + "methods": ["GET", "POST"], + "path": "/users/{id}", + "handler": "\\App\\Controllers\\UserController" +} +``` + +### Route Properties + +- **methods**: Array of HTTP methods (GET, POST, PUT, DELETE, etc.) +- **path**: URL path with optional parameters +- **handler**: Fully qualified class name of the controller + +### HTTP Methods + +```json +// Single method +{ "methods": ["GET"], "path": "/users", "handler": "\\App\\Controllers\\UserController" } + +// Multiple methods +{ "methods": ["GET", "POST"], "path": "/users", "handler": "\\App\\Controllers\\UserController" } + +// All methods (empty array) +{ "methods": [], "path": "/", "handler": "\\App\\Controllers\\IndexController" } +``` + +## Route Parameters + +SPIN supports route parameters using curly brace syntax. + +### Parameter Examples + +```json +// Single parameter +{ "methods": ["GET"], "path": "/users/{id}", "handler": "\\App\\Controllers\\UserController" } + +// Multiple parameters +{ "methods": ["GET"], "path": "/users/{id}/posts/{postId}", "handler": "\\App\\Controllers\\PostController" } + +// Optional parameters (using ?) +{ "methods": ["GET"], "path": "/users/{id?}", "handler": "\\App\\Controllers\\UserController" } +``` + +## Controllers + +Controllers handle the business logic for routes and extend SPIN's base controller classes. + +### Controller Structure + +```php +'PageTitle', 'user'=>'Friend']; + + # Render view + $html = $this->engine->render('pages::index', $model); + + # Send the generated html + return response($html); + } +} +``` + +### HTTP Method Handlers + +SPIN controllers use specific method names to handle different HTTP requests: + +- `handleGET(array $args)` - Handles GET requests +- `handlePOST(array $args)` - Handles POST requests +- `handlePUT(array $args)` - Handles PUT requests +- `handleDELETE(array $args)` - Handles DELETE requests +- `handlePATCH(array $args)` - Handles PATCH requests + +### Controller Parameters + +The `$args` parameter contains route parameters as key-value pairs: + +```php +// Route: /users/{id}/posts/{postId} +// URL: /users/123/posts/456 + +public function handleGET(array $args) +{ + $userId = $args['id']; // "123" + $postId = $args['postId']; // "456" + + // Controller logic here +} +``` + +## Abstract Controllers + +SPIN provides abstract controller classes for common functionality. + +### AbstractPlatesController + +For template-based responses using the Plates template engine: + +```php +engine->render('users::show', ['user' => $user]); + return response($html); + } +} +``` + +### AbstractRestController + +For API responses returning JSON: + +```php + 'success', 'message' => 'Hello World']; + return responseJson($data); + } +} +``` + +## Middleware Integration + +Routes can have middleware applied at multiple levels: + +### Common Middleware + +Applied to all routes in the application: + +```json +{ + "common": { + "before": ["\\App\\Middlewares\\RequestIdBeforeMiddleware"], + "after": ["\\App\\Middlewares\\ResponseLogAfterMiddleware"] + } +} +``` + +### Group Middleware + +Applied to all routes in a specific group: + +```json +{ + "name": "Private", + "prefix": "/api/v1", + "before": ["\\App\\Middlewares\\AuthHttpBeforeMiddleware"], + "routes": [ + { "methods": ["GET"], "path": "/profile", "handler": "\\App\\Controllers\\ProfileController" } + ] +} +``` + +### Route-Specific Middleware + +Individual routes can specify additional middleware: + +```json +{ + "methods": ["POST"], + "path": "/users", + "handler": "\\App\\Controllers\\UserController", + "middleware": ["\\App\\Middlewares\\ValidationMiddleware"] +} +``` + +## Error Handling + +SPIN provides error controllers for handling HTTP error responses. + +### Error Controllers + +```json +{ + "errors": { + "4xx": "\\App\\Controllers\\Error4xxController", + "5xx": "\\App\\Controllers\\Error5xxController" + } +} +``` + +### Error Controller Example + +```php +engine->render('errors::4xx', ['status' => $statusCode]); + return response($html, $statusCode); + } +} +``` + +## Response Handling + +SPIN provides helper functions for creating responses. + +### Response Helpers + +```php +// HTML response +return response($html); + +// JSON response +return responseJson($data); + +// Response with status code +return response($html, 201); + +// Response with headers +return response($html, 200, ['Content-Type' => 'text/html']); + +// JSON response with status +return responseJson($data, 201); +``` + +## Route Caching + +SPIN caches route definitions for performance. Changes to route configuration require an application restart. + +## Best Practices + +1. **Route Organization**: Group related routes together with descriptive names +2. **Middleware Order**: Apply authentication middleware early in the pipeline +3. **Error Handling**: Provide meaningful error responses for different HTTP status codes +4. **Controller Methods**: Use specific HTTP method handlers for better organization +5. **Route Parameters**: Use descriptive parameter names and validate them in controllers +6. **Response Consistency**: Use consistent response formats across your API +7. **Documentation**: Document route groups and their purposes + +## Example Route Configuration + +Here's a complete example of a typical SPIN application route configuration: + +```json +{ + "common": { + "before": ["\\App\\Middlewares\\RequestIdBeforeMiddleware"], + "after": [ + "\\App\\Middlewares\\ResponseTimeAfterMiddleware", + "\\App\\Middlewares\\ResponseLogAfterMiddleware" + ] + }, + "groups": [ + { + "name": "Public API", + "prefix": "/api/v1", + "before": ["\\App\\Middlewares\\CorsBeforeMiddleware"], + "routes": [ + { "methods": ["GET"], "path": "/health", "handler": "\\App\\Controllers\\Api\\HealthController" }, + { "methods": ["POST"], "path": "/auth/login", "handler": "\\App\\Controllers\\Api\\AuthController" } + ] + }, + { + "name": "Protected API", + "prefix": "/api/v1", + "before": [ + "\\App\\Middlewares\\AuthHttpBeforeMiddleware", + "\\App\\Middlewares\\RateLimitBeforeMiddleware" + ], + "routes": [ + { "methods": ["GET"], "path": "/users/{id}", "handler": "\\App\\Controllers\\Api\\UserController" }, + { "methods": ["POST"], "path": "/users", "handler": "\\App\\Controllers\\Api\\UserController" }, + { "methods": ["PUT"], "path": "/users/{id}", "handler": "\\App\\Controllers\\Api\\UserController" }, + { "methods": ["DELETE"], "path": "/users/{id}", "handler": "\\App\\Controllers\\Api\\UserController" } + ] + }, + { + "name": "Web Pages", + "prefix": "", + "before": ["\\App\\Middlewares\\SessionBeforeMiddleware"], + "routes": [ + { "methods": [], "path": "/", "handler": "\\App\\Controllers\\IndexController" }, + { "methods": ["GET"], "path": "/about", "handler": "\\App\\Controllers\\PageController" }, + { "methods": ["GET"], "path": "/contact", "handler": "\\App\\Controllers\\PageController" } + ] + } + ], + "errors": { + "4xx": "\\App\\Controllers\\Error4xxController", + "5xx": "\\App\\Controllers\\Error5xxController" + } +} +``` + +This routing system provides a clean, organized way to define your application's URL structure while maintaining flexibility for middleware and controller organization. diff --git a/doc/Security.md b/doc/Security.md new file mode 100644 index 0000000..0f3a7e3 --- /dev/null +++ b/doc/Security.md @@ -0,0 +1,761 @@ +# Security + +SPIN Framework provides a robust security foundation with built-in authentication, authorization, and security middleware. This guide covers the security features and best practices for building secure SPIN applications. + +## Authentication + +SPIN supports multiple authentication methods through middleware and helper functions. + +### HTTP Basic Authentication + +```php +username = config('auth.basic.username'); + $this->password = config('auth.basic.password'); + return true; + } + + public function handle(array $args): bool + { + $request = getRequest(); + $authHeader = $request->getHeaderLine('Authorization'); + + if (empty($authHeader) || !str_starts_with($authHeader, 'Basic ')) { + return $this->challenge(); + } + + $credentials = base64_decode(substr($authHeader, 6)); + [$username, $password] = explode(':', $credentials, 2); + + if ($username === $this->username && $password === $this->password) { + container('user', ['username' => $username, 'authenticated' => true]); + return true; + } + + return $this->challenge(); + } + + private function challenge(): bool + { + response('', 401, [ + 'WWW-Authenticate' => 'Basic realm="Secure Area"' + ]); + return false; + } +} +``` + +### API Key Authentication + +```php +validKeys = config('auth.api_keys', []); + return true; + } + + public function handle(array $args): bool + { + $request = getRequest(); + $apiKey = $request->getHeaderLine('X-API-Key'); + + if (empty($apiKey)) { + return $this->unauthorized('API key required'); + } + + if (!in_array($apiKey, $this->validKeys)) { + return $this->unauthorized('Invalid API key'); + } + + container('api_key', $apiKey); + return true; + } + + private function unauthorized(string $message): bool + { + responseJson(['error' => $message], 401); + return false; + } +} +``` + +### JWT Token Authentication + +```php +secret = config('application.secret'); + return true; + } + + public function handle(array $args): bool + { + $request = getRequest(); + $authHeader = $request->getHeaderLine('Authorization'); + + if (empty($authHeader) || !str_starts_with($authHeader, 'Bearer ')) { + return $this->unauthorized('Bearer token required'); + } + + $token = substr($authHeader, 7); + + try { + $payload = JWT::decode($token, $this->secret, ['HS256']); + + if (is_null($payload)) { + return $this->unauthorized('Invalid token'); + } + + // Check token expiration + if (isset($payload->exp) && $payload->exp < time()) { + return $this->unauthorized('Token expired'); + } + + // Store user data in container + container('jwt:payload', $payload); + container('user', [ + 'id' => $payload->sub ?? null, + 'email' => $payload->email ?? null, + 'roles' => $payload->roles ?? [] + ]); + + return true; + + } catch (\Exception $e) { + logger()->error('JWT validation failed', [ + 'rid' => container('requestId'), + 'error' => $e->getMessage() + ]); + return $this->unauthorized('Invalid token'); + } + } + + private function unauthorized(string $message): bool + { + responseJson(['error' => $message], 401); + return false; + } +} +``` + +## Authorization + +### Role-Based Access Control + +```php +requiredRoles = $args['roles'] ?? []; + return true; + } + + public function handle(array $args): bool + { + $user = container('user'); + + if (!$user || !isset($user['roles'])) { + return $this->forbidden('User not authenticated'); + } + + $userRoles = $user['roles']; + $hasRequiredRole = false; + + foreach ($this->requiredRoles as $requiredRole) { + if (in_array($requiredRole, $userRoles)) { + $hasRequiredRole = true; + break; + } + } + + if (!$hasRequiredRole) { + return $this->forbidden('Insufficient permissions'); + } + + return true; + } + + private function forbidden(string $message): bool + { + responseJson(['error' => $message], 403); + return false; + } +} +``` + +### Resource Ownership + +```php +resourceType = $args['resource'] ?? 'user'; + return true; + } + + public function handle(array $args): bool + { + $user = container('user'); + $resourceId = $args['id'] ?? null; + + if (!$user || !$resourceId) { + return $this->forbidden('Access denied'); + } + + // Check if user owns the resource + if (!$this->userOwnsResource($user['id'], $resourceId, $this->resourceType)) { + return $this->forbidden('Access denied'); + } + + return true; + } + + private function userOwnsResource(int $userId, string $resourceId, string $resourceType): bool + { + // Implement resource ownership logic here + // This is a simplified example + return true; + } + + private function forbidden(string $message): bool + { + responseJson(['error' => $message], 403); + return false; + } +} +``` + +## Input Validation + +### Request Validation Middleware + +```php +rules = $args['rules'] ?? []; + return true; + } + + public function handle(array $args): bool + { + $request = getRequest(); + $data = $request->getParsedBody() ?? []; + + $errors = $this->validate($data, $this->rules); + + if (!empty($errors)) { + responseJson(['errors' => $errors], 422); + return false; + } + + return true; + } + + private function validate(array $data, array $rules): array + { + $errors = []; + + foreach ($rules as $field => $rule) { + if (!$this->validateField($data[$field] ?? null, $rule)) { + $errors[$field] = "Field {$field} is invalid"; + } + } + + return $errors; + } + + private function validateField($value, string $rule): bool + { + switch ($rule) { + case 'required': + return !empty($value); + case 'email': + return filter_var($value, FILTER_VALIDATE_EMAIL) !== false; + case 'numeric': + return is_numeric($value); + default: + return true; + } + } +} +``` + +### SQL Injection Prevention + +SPIN uses PDO with prepared statements to prevent SQL injection: + +```php + 'Invalid user ID'], 400); + } + + // Use prepared statements + $stmt = $this->db->prepare('SELECT * FROM users WHERE id = ?'); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + + if (!$user) { + return responseJson(['error' => 'User not found'], 404); + } + + return responseJson($user); + } +} +``` + +## XSS Prevention + +### Output Escaping + +```php + $escapedInput, + 'safe' => true + ]; + + $html = $this->engine->render('pages::search', $model); + return response($html); + } +} +``` + +### Content Security Policy + +```php +withHeader('Content-Security-Policy', $csp); + + // Other security headers + $response = $response->withHeader('X-Content-Type-Options', 'nosniff'); + $response = $response->withHeader('X-Frame-Options', 'DENY'); + $response = $response->withHeader('X-XSS-Protection', '1; mode=block'); + $response = $response->withHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + + return true; + } +} +``` + +## CSRF Protection + +### CSRF Token Middleware + +```php +getMethod() === 'GET') { + return true; + } + + $token = $request->getParsedBody()['csrf_token'] ?? + $request->getHeaderLine('X-CSRF-Token'); + + $session = container('session'); + $expectedToken = $session['csrf_token'] ?? null; + + if (!$token || $token !== $expectedToken) { + responseJson(['error' => 'CSRF token mismatch'], 403); + return false; + } + + return true; + } +} +``` + +### CSRF Token Generation + +```php + $csrfToken]; + $html = $this->engine->render('forms::create', $model); + return response($html); + } +} +``` + +## File Upload Security + +### File Upload Validation + +```php +allowedTypes = config('uploads.allowed_types', ['jpg', 'png', 'pdf']); + $this->maxSize = config('uploads.max_size', 5 * 1024 * 1024); // 5MB + return true; + } + + public function handle(array $args): bool + { + $files = getRequest()->getUploadedFiles(); + + foreach ($files as $file) { + if (!$this->validateFile($file)) { + return false; + } + } + + return true; + } + + private function validateFile($file): bool + { + // Check file size + if ($file->getSize() > $this->maxSize) { + responseJson(['error' => 'File too large'], 413); + return false; + } + + // Check file type + $extension = strtolower(pathinfo($file->getClientFilename(), PATHINFO_EXTENSION)); + if (!in_array($extension, $this->allowedTypes)) { + responseJson(['error' => 'File type not allowed'], 400); + return false; + } + + // Check MIME type + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimeType = finfo_file($finfo, $file->getStream()->getMetadata('uri')); + finfo_close($finfo); + + $allowedMimes = [ + 'jpg' => 'image/jpeg', + 'png' => 'image/png', + 'pdf' => 'application/pdf' + ]; + + if (!isset($allowedMimes[$extension]) || $allowedMimes[$extension] !== $mimeType) { + responseJson(['error' => 'Invalid file content'], 400); + return false; + } + + return true; + } +} +``` + +## Rate Limiting + +### Rate Limiting Middleware + +```php +maxRequests = $args['max_requests'] ?? 100; + $this->timeWindow = $args['time_window'] ?? 60; // seconds + return true; + } + + public function handle(array $args): bool + { + $request = getRequest(); + $ip = $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown'; + $key = "rate_limit:{$ip}"; + + $current = cache()->get($key, 0); + + if ($current >= $this->maxRequests) { + responseJson(['error' => 'Too many requests'], 429); + return false; + } + + // Increment request count + cache()->set($key, $current + 1, $this->timeWindow); + + // Add rate limit headers + $response = getResponse(); + $response = $response->withHeader('X-RateLimit-Limit', $this->maxRequests); + $response = $response->withHeader('X-RateLimit-Remaining', $this->maxRequests - $current - 1); + $response = $response->withHeader('X-RateLimit-Reset', time() + $this->timeWindow); + + return true; + } +} +``` + +## Security Configuration + +### Security Settings in Configuration + +```json +{ + "security": { + "csrf": { + "enabled": true, + "token_length": 32 + }, + "rate_limiting": { + "enabled": true, + "default_limit": 100, + "default_window": 60 + }, + "file_uploads": { + "max_size": 5242880, + "allowed_types": ["jpg", "png", "pdf"], + "scan_viruses": true + }, + "headers": { + "content_security_policy": "default-src 'self'", + "x_frame_options": "DENY", + "x_content_type_options": "nosniff" + } + } +} +``` + +## Security Best Practices + +1. **Always Validate Input**: Never trust user input +2. **Use Prepared Statements**: Prevent SQL injection +3. **Escape Output**: Prevent XSS attacks +4. **Implement CSRF Protection**: For all state-changing operations +5. **Use HTTPS**: Encrypt all communications +6. **Implement Rate Limiting**: Prevent abuse +7. **Validate File Uploads**: Check type, size, and content +8. **Use Strong Authentication**: Implement proper auth mechanisms +9. **Log Security Events**: Monitor for suspicious activity +10. **Keep Dependencies Updated**: Patch security vulnerabilities + +## Security Checklist + +- [ ] Input validation implemented +- [ ] Output escaping implemented +- [ ] SQL injection prevention +- [ ] XSS protection +- [ ] CSRF protection +- [ ] File upload validation +- [ ] Authentication middleware +- [ ] Authorization checks +- [ ] Rate limiting +- [ ] Security headers +- [ ] HTTPS enforcement +- [ ] Error handling (no sensitive data exposure) +- [ ] Logging and monitoring +- [ ] Dependencies updated +- [ ] Security testing performed + +## Security Testing + +### Testing Authentication + +```php + 'admin', 'password' => 'password']; + + $result = $middleware->handle($args); + + $this->assertTrue($result); + } + + public function testInvalidCredentials() + { + $middleware = new BasicAuthMiddleware(); + $args = ['username' => 'admin', 'password' => 'wrong']; + + $result = $middleware->handle($args); + + $this->assertFalse($result); + } +} +``` + +### Testing Authorization + +```php + ['admin']]; + + // Mock user with admin role + container('user', ['roles' => ['admin', 'user']]); + + $result = $middleware->handle($args); + + $this->assertTrue($result); + } + + public function testUserWithoutRequiredRole() + { + $middleware = new RoleMiddleware(); + $args = ['roles' => ['admin']]; + + // Mock user without admin role + container('user', ['roles' => ['user']]); + + $result = $middleware->handle($args); + + $this->assertFalse($result); + } +} +``` + +This security guide provides a comprehensive foundation for building secure SPIN applications. Always follow security best practices and regularly audit your security measures. diff --git a/doc/Testing.md b/doc/Testing.md new file mode 100644 index 0000000..58259bd --- /dev/null +++ b/doc/Testing.md @@ -0,0 +1,1082 @@ +# Testing + +SPIN Framework provides comprehensive testing support to ensure your application works correctly and reliably. This guide covers unit testing, integration testing, and testing best practices. + +## Testing Overview + +### Testing Types + +- **Unit Tests** - Test individual components in isolation +- **Integration Tests** - Test how components work together +- **Feature Tests** - Test complete features end-to-end +- **Performance Tests** - Test application performance and scalability + +### Testing Tools + +- **PHPUnit** - Primary testing framework +- **Mockery** - Mocking and stubbing library +- **Faker** - Data generation for tests +- **Code Coverage** - Test coverage analysis + +## Setting Up Testing + +### Installation + +```bash +# Install testing dependencies +composer require --dev phpunit/phpunit +composer require --dev mockery/mockery +composer require --dev fakerphp/faker +``` + +### PHPUnit Configuration + +```xml + + + + + + tests/Unit + + + tests/Integration + + + tests/Feature + + + + + + src + + + vendor + tests + + + + + + + + + + + + + +``` + +### Test Bootstrap + +```php +assertEquals($expected, $result); + } + + public function testArrayFlattenWithEmptyArray() + { + $input = []; + $expected = []; + + $result = ArrayHelper::flatten($input); + + $this->assertEquals($expected, $result); + } + + public function testArrayFlattenWithNull() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Input must be an array'); + + ArrayHelper::flatten(null); + } +} +``` + +### Testing Controllers + +```php +userService = Mockery::mock(UserService::class); + $this->controller = new UserController($this->userService); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function testIndexReturnsUsers() + { + $expectedUsers = [ + ['id' => 1, 'name' => 'John Doe'], + ['id' => 2, 'name' => 'Jane Smith'] + ]; + + $this->userService + ->shouldReceive('getAllUsers') + ->once() + ->andReturn($expectedUsers); + + $result = $this->controller->index(); + + $this->assertEquals($expectedUsers, $result); + } + + public function testShowReturnsUser() + { + $userId = 1; + $expectedUser = ['id' => 1, 'name' => 'John Doe']; + + $this->userService + ->shouldReceive('getUserById') + ->with($userId) + ->once() + ->andReturn($expectedUser); + + $result = $this->controller->show(['id' => $userId]); + + $this->assertEquals($expectedUser, $result); + } + + public function testShowThrowsExceptionForInvalidId() + { + $this->expectException(\InvalidArgumentException::class); + + $this->controller->show(['id' => 'invalid']); + } +} +``` + +### Testing Services + +```php +userRepository = Mockery::mock(UserRepository::class); + $this->userService = new UserService($this->userRepository); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function testCreateUser() + { + $userData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123' + ]; + + $expectedUser = new User($userData); + + $this->userRepository + ->shouldReceive('create') + ->with($userData) + ->once() + ->andReturn($expectedUser); + + $result = $this->userService->createUser($userData); + + $this->assertInstanceOf(User::class, $result); + $this->assertEquals($userData['name'], $result->name); + $this->assertEquals($userData['email'], $result->email); + } + + public function testCreateUserWithInvalidData() + { + $userData = [ + 'name' => '', + 'email' => 'invalid-email', + 'password' => '123' + ]; + + $this->expectException(\InvalidArgumentException::class); + + $this->userService->createUser($userData); + } +} +``` + +## Integration Testing + +### Testing Database Operations + +```php +pdo = new \PDO('sqlite::memory:'); + $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + + // Create test tables + $this->createTables(); + + $this->userRepository = new UserRepository($this->pdo); + } + + private function createTables(): void + { + $this->pdo->exec(" + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + "); + } + + public function testCreateAndFindUser() + { + $userData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => password_hash('password123', PASSWORD_DEFAULT) + ]; + + $user = $this->userRepository->create($userData); + + $this->assertInstanceOf(User::class, $user); + $this->assertEquals($userData['name'], $user->name); + $this->assertEquals($userData['email'], $user->email); + + // Find the user + $foundUser = $this->userRepository->findById($user->id); + + $this->assertInstanceOf(User::class, $foundUser); + $this->assertEquals($user->id, $foundUser->id); + } + + public function testUpdateUser() + { + // Create user first + $user = $this->userRepository->create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => password_hash('password123', PASSWORD_DEFAULT) + ]); + + // Update user + $updatedData = ['name' => 'John Smith']; + $updatedUser = $this->userRepository->update($user->id, $updatedData); + + $this->assertEquals($updatedData['name'], $updatedUser->name); + + // Verify in database + $foundUser = $this->userRepository->findById($user->id); + $this->assertEquals($updatedData['name'], $foundUser->name); + } + + public function testDeleteUser() + { + // Create user first + $user = $this->userRepository->create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => password_hash('password123', PASSWORD_DEFAULT) + ]); + + // Delete user + $this->userRepository->delete($user->id); + + // Verify user is deleted + $foundUser = $this->userRepository->findById($user->id); + $this->assertNull($foundUser); + } +} +``` + +### Testing API Endpoints + +```php +app = new Application(); + $this->app->bootstrap(); + } + + public function testGetUsersEndpoint() + { + $request = $this->createRequest('GET', '/api/users'); + $response = $this->app->handle($request); + + $this->assertEquals(200, $response->getStatusCode()); + + $data = json_decode($response->getBody()->getContents(), true); + $this->assertIsArray($data); + } + + public function testCreateUserEndpoint() + { + $userData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123' + ]; + + $request = $this->createRequest('POST', '/api/users', $userData); + $response = $this->app->handle($request); + + $this->assertEquals(201, $response->getStatusCode()); + + $data = json_decode($response->getBody()->getContents(), true); + $this->assertEquals($userData['name'], $data['name']); + $this->assertEquals($userData['email'], $data['email']); + } + + public function testCreateUserWithInvalidData() + { + $userData = [ + 'name' => '', + 'email' => 'invalid-email' + ]; + + $request = $this->createRequest('POST', '/api/users', $userData); + $response = $this->app->handle($request); + + $this->assertEquals(422, $response->getStatusCode()); + + $data = json_decode($response->getBody()->getContents(), true); + $this->assertArrayHasKey('errors', $data); + } + + private function createRequest(string $method, string $uri, array $data = []): \Psr\Http\Message\RequestInterface + { + $request = new \GuzzleHttp\Psr7\Request($method, $uri); + + if (!empty($data)) { + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody(\GuzzleHttp\Psr7\Utils::streamFor(json_encode($data))); + } + + return $request; + } +} +``` + +## Feature Testing + +### Testing Complete Features + +```php +app = new Application(); + $this->app->bootstrap(); + } + + public function testUserCanRegister() + { + $userData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123' + ]; + + // Submit registration form + $request = $this->createRequest('POST', '/register', $userData); + $response = $this->app->handle($request); + + $this->assertEquals(302, $response->getStatusCode()); // Redirect after success + + // Verify user was created + $user = $this->getUserByEmail($userData['email']); + $this->assertNotNull($user); + $this->assertEquals($userData['name'], $user->name); + + // Verify user can login + $loginResponse = $this->attemptLogin($userData['email'], $userData['password']); + $this->assertEquals(302, $loginResponse->getStatusCode()); + } + + public function testUserCannotRegisterWithInvalidData() + { + $invalidData = [ + 'name' => '', + 'email' => 'invalid-email', + 'password' => '123', + 'password_confirmation' => '456' + ]; + + $request = $this->createRequest('POST', '/register', $invalidData); + $response = $this->app->handle($request); + + $this->assertEquals(422, $response->getStatusCode()); + + $data = json_decode($response->getBody()->getContents(), true); + $this->assertArrayHasKey('errors', $data); + $this->assertArrayHasKey('name', $data['errors']); + $this->assertArrayHasKey('email', $data['errors']); + $this->assertArrayHasKey('password', $data['errors']); + } + + public function testUserCannotRegisterWithExistingEmail() + { + // Create first user + $this->createUser([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123' + ]); + + // Try to register with same email + $userData = [ + 'name' => 'Jane Smith', + 'email' => 'john@example.com', + 'password' => 'password456', + 'password_confirmation' => 'password456' + ]; + + $request = $this->createRequest('POST', '/register', $userData); + $response = $this->app->handle($request); + + $this->assertEquals(422, $response->getStatusCode()); + + $data = json_decode($response->getBody()->getContents(), true); + $this->assertArrayHasKey('email', $data['errors']); + } + + private function createRequest(string $method, string $uri, array $data = []): \Psr\Http\Message\RequestInterface + { + $request = new \GuzzleHttp\Psr7\Request($method, $uri); + + if (!empty($data)) { + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody(\GuzzleHttp\Psr7\Utils::streamFor(json_encode($data))); + } + + return $request; + } + + private function getUserByEmail(string $email) + { + // Implementation to get user from database + return null; + } + + private function createUser(array $data) + { + // Implementation to create user in database + } + + private function attemptLogin(string $email, string $password): \Psr\Http\Message\ResponseInterface + { + $loginData = ['email' => $email, 'password' => $password]; + $request = $this->createRequest('POST', '/login', $loginData); + return $this->app->handle($request); + } +} +``` + +## Testing Middleware + +### Testing Custom Middleware + +```php +middleware = new AuthMiddleware(); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function testMiddlewareAllowsAuthenticatedRequest() + { + $request = Mockery::mock(RequestInterface::class); + $response = Mockery::mock(ResponseInterface::class); + + $request->shouldReceive('getHeaderLine') + ->with('Authorization') + ->andReturn('Bearer valid-token'); + + $next = function ($req, $res) { + return $res; + }; + + $result = $this->middleware->process($request, $response, $next); + + $this->assertInstanceOf(ResponseInterface::class, $result); + } + + public function testMiddlewareRejectsUnauthenticatedRequest() + { + $request = Mockery::mock(RequestInterface::class); + $response = Mockery::mock(ResponseInterface::class); + + $request->shouldReceive('getHeaderLine') + ->with('Authorization') + ->andReturn(''); + + $next = function ($req, $res) { + return $res; + }; + + $result = $this->middleware->process($request, $response, $next); + + $this->assertEquals(401, $result->getStatusCode()); + } + + public function testMiddlewareRejectsInvalidToken() + { + $request = Mockery::mock(RequestInterface::class); + $response = Mockery::mock(ResponseInterface::class); + + $request->shouldReceive('getHeaderLine') + ->with('Authorization') + ->andReturn('Bearer invalid-token'); + + $next = function ($req, $res) { + return $res; + }; + + $result = $this->middleware->process($request, $response, $next); + + $this->assertEquals(401, $result->getStatusCode()); + } +} +``` + +## Testing Utilities + +### Test Data Factories + +```php + self::$faker->name, + 'email' => self::$faker->unique()->safeEmail, + 'password' => 'password123' + ]; + + $data = array_merge($defaults, $attributes); + + return new User($data); + } + + public static function create(array $attributes = []): User + { + $user = self::make($attributes); + + // Save to database + $user->save(); + + return $user; + } + + public static function createMany(int $count, array $attributes = []): array + { + $users = []; + + for ($i = 0; $i < $count; $i++) { + $users[] = self::create($attributes); + } + + return $users; + } +} +``` + +### Using Test Factories + +```php +userService->getAllUsers(); + + $this->assertCount(3, $result); + $this->assertContainsOnlyInstancesOf(User::class, $result); + } + + public function testGetUserById() + { + $user = UserFactory::create(['name' => 'John Doe']); + + $result = $this->userService->getUserById($user->id); + + $this->assertInstanceOf(User::class, $result); + $this->assertEquals('John Doe', $result->name); + } +} +``` + +## Performance Testing + +### Basic Performance Tests + +```php +userService = new UserService(); + } + + public function testGetAllUsersPerformance() + { + $startTime = microtime(true); + + $users = $this->userService->getAllUsers(); + + $endTime = microtime(true); + $executionTime = ($endTime - $startTime) * 1000; // Convert to milliseconds + + $this->assertLessThan(100, $executionTime, 'Query took longer than 100ms'); + $this->assertNotEmpty($users); + } + + public function testCreateUserPerformance() + { + $userData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123' + ]; + + $startTime = microtime(true); + + $user = $this->userService->createUser($userData); + + $endTime = microtime(true); + $executionTime = ($endTime - $startTime) * 1000; + + $this->assertLessThan(50, $executionTime, 'User creation took longer than 50ms'); + $this->assertInstanceOf(User::class, $user); + } +} +``` + +## Code Coverage + +### Running Tests with Coverage + +```bash +# Generate HTML coverage report +./vendor/bin/phpunit --coverage-html coverage + +# Generate XML coverage report +./vendor/bin/phpunit --coverage-clover coverage.xml + +# Generate text coverage report +./vendor/bin/phpunit --coverage-text +``` + +### Coverage Configuration + +```xml + + + src + app + + + vendor + tests + storage + + + + + + + +``` + +## Testing Best Practices + +### 1. Test Organization + +- Group related tests in test suites +- Use descriptive test method names +- Follow AAA pattern (Arrange, Act, Assert) +- Keep tests focused and simple + +### 2. Test Data Management + +- Use factories for test data generation +- Clean up test data after each test +- Use database transactions for test isolation +- Avoid hardcoded test data + +### 3. Mocking and Stubbing + +- Mock external dependencies +- Stub complex operations +- Verify mock interactions +- Use realistic test data + +### 4. Test Coverage + +- Aim for high test coverage (80%+) +- Focus on critical business logic +- Test edge cases and error conditions +- Don't test framework code + +### 5. Performance Considerations + +- Keep tests fast +- Use in-memory databases for testing +- Avoid unnecessary I/O operations +- Mock external services + +## Running Tests + +### Command Line + +```bash +# Run all tests +./vendor/bin/phpunit + +# Run specific test suite +./vendor/bin/phpunit --testsuite Unit + +# Run specific test file +./vendor/bin/phpunit tests/Unit/Controllers/UserControllerTest.php + +# Run specific test method +./vendor/bin/phpunit --filter testCreateUser + +# Run tests with verbose output +./vendor/bin/phpunit --verbose + +# Run tests and stop on first failure +./vendor/bin/phpunit --stop-on-failure +``` + +### Continuous Integration + +```yaml +# .github/workflows/tests.yml +name: Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + extensions: mbstring, xml, ctype, iconv, intl, pdo_sqlite + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run tests + run: ./vendor/bin/phpunit --coverage-clover coverage.xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1 + with: + file: ./coverage.xml +``` + +## Test Examples + +### Complete Test Class + +```php +templateService = Mockery::mock(TemplateService::class); + $this->mailerService = Mockery::mock(MailerService::class); + + $this->emailService = new EmailService( + $this->templateService, + $this->mailerService + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function testSendWelcomeEmail() + { + $user = [ + 'name' => 'John Doe', + 'email' => 'john@example.com' + ]; + + $expectedTemplate = '

Welcome John Doe!

'; + $expectedSubject = 'Welcome to Our Application'; + + $this->templateService + ->shouldReceive('render') + ->with('emails.welcome', $user) + ->once() + ->andReturn($expectedTemplate); + + $this->mailerService + ->shouldReceive('send') + ->with($user['email'], $expectedSubject, $expectedTemplate) + ->once() + ->andReturn(true); + + $result = $this->emailService->sendWelcomeEmail($user); + + $this->assertTrue($result); + } + + public function testSendWelcomeEmailWithInvalidUser() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('User data is required'); + + $this->emailService->sendWelcomeEmail([]); + } + + public function testSendWelcomeEmailWithMissingEmail() + { + $user = ['name' => 'John Doe']; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('User email is required'); + + $this->emailService->sendWelcomeEmail($user); + } + + public function testSendWelcomeEmailWhenMailerFails() + { + $user = [ + 'name' => 'John Doe', + 'email' => 'john@example.com' + ]; + + $this->templateService + ->shouldReceive('render') + ->with('emails.welcome', $user) + ->once() + ->andReturn('

Welcome John Doe!

'); + + $this->mailerService + ->shouldReceive('send') + ->once() + ->andReturn(false); + + $result = $this->emailService->sendWelcomeEmail($user); + + $this->assertFalse($result); + } +} +``` + +By following these testing practices, you can ensure your SPIN Framework application is reliable, maintainable, and bug-free. Comprehensive testing gives you confidence in your code and makes refactoring and updates much safer. diff --git a/readme.md b/readme.md index b61a602..3312cb2 100644 --- a/readme.md +++ b/readme.md @@ -3,120 +3,281 @@ [![License](https://poser.pugx.org/nofuzz/framework/license)](https://packagist.org/packages/celarius/spin-framework) [![PHP8 Ready](https://img.shields.io/badge/PHP8-ready-green.svg)](https://packagist.org/packages/celarius/spin-framework) [![Unit Tests](https://github.com/Celarius/spin-framework/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/Celarius/spin-framework/actions/workflows/unit-tests.yml) +[![Code Quality](https://img.shields.io/badge/code%20quality-A-green.svg)](https://github.com/Celarius/spin-framework) +[![Maintenance](https://img.shields.io/badge/maintained-yes-green.svg)](https://github.com/Celarius/spin-framework) -# SPIN - A super lightweight PHP UI/REST framework +

+ SPIN Framework Logo +

-SPIN is a application framework for making Web UI's and REST API's quickly and effectively with PHP. It uses [PSR standards](http://www.php-fig.org/psr/) -for most things, and allows for plugging in almost any PSR compatible component, such as loggers, HTTP libraries etc. +

+ A super lightweight, modern PHP framework for building web applications and REST APIs +

- +## 🚀 About SPIN Framework - +SPIN is a lightweight, high-performance PHP framework designed for building modern web applications and REST APIs. Built with PHP 8+ and following PSR standards, SPIN provides a clean, intuitive foundation for developers who want speed, flexibility, and simplicity without the overhead of larger frameworks. -- [SPIN - A super lightweight PHP UI/REST framework](#spin---a-super-lightweight-php-uirest-framework) -- [1. Features](#1-features) - - [1.1. PSR based integrations](#11-psr-based-integrations) -- [2. Installation](#2-installation) - - [2.1. Using the spin-skeleton](#21-using-the-spin-skeleton) - - [2.2. Testing](#22-testing) -- [3. Technical Details](#3-technical-details) - - [3.1. Apache configuration](#31-apache-configuration) - - [3.2. Nginx configuration](#32-nginx-configuration) +### ✨ Why Choose SPIN? - +- **🚀 Lightning Fast** - Minimal overhead, optimized for performance +- **🔧 PSR Compliant** - Built on industry standards for maximum compatibility +- **📱 Modern PHP 8+** - Leverages the latest PHP features and performance improvements +- **🔄 Flexible Architecture** - Easy to extend and customize for your specific needs +- **📚 Comprehensive** - Built-in support for routing, middleware, caching, databases, and more +- **🌐 Platform Agnostic** - Works seamlessly on Windows, Linux, and macOS -# 1. Features -* PHP 8+ -* Platform agnostic. (Windows, \*nix) -* Routing engine, with route groups -* Middleware -* Containers -* Composer driven in packages/extensions -* PDO based DB connections (MySql,PostgreSql,Oracle,CockroachDb,Firebird,Sqlite ...) -* Extendable with other frameworks (ORM, Templates etc.) +## 📋 Requirements +- **PHP**: 8.0 or higher +- **Extensions**: PDO, JSON, OpenSSL, Mbstring +- **Web Server**: Apache, Nginx, or any PSR-7 compatible server +- **Database**: MySQL, PostgreSQL, SQLite, CockroachDB, Firebird, or any PDO-compatible database -## 1.1. PSR based integrations -* Logger (PSR-3) Defaults to [Monolog](https://github.com/Seldaek/monolog) -* HTTP Message (PSR-7). Defaults to [Guzzle](https://github.com/guzzle/guzzle) -* Container (PSR-11). Defaults to [The Leauge Container](http://container.thephpleague.com/) -* SimpleCache (PSR-16). Defaults to APCu SimpleCache -* HTTP Factories (PSR-17) +## 🛠️ Installation +### Quick Start with Composer -# 2. Installation -Installing spin-framework as standalone with composer: ```bash composer require celarius/spin-framework ``` -## 2.1. Using the spin-skeleton -To install and use the spin-framework it is highly recommended to start by cloning the [spin-skeleton](https://github.com/Celarius/spin-skeleton) and -running `composer update -o` in the folder. This will download all needed packages, and create a template skeleton project, containing example -configs, routes, controllers and many other things. +### Using the SPIN Skeleton (Recommended) -## 2.2. Testing -On Windows based systems simply type -```txt -.\phpunit.cmd +For the best development experience, start with our official skeleton project: + +```bash +# Clone the skeleton +git clone https://github.com/Celarius/spin-skeleton.git my-spin-app +cd my-spin-app + +# Install dependencies +composer install + +# Start development server +php -S localhost:8000 -t src/public ``` -At the command prompt and all tests will be executed. -# 3. Technical Details -* [Cache](doc/Cache.md) -* [Helpers](doc/Helpers.md) -* [Databases](doc/Databases.md) -* [Uploading files](doc/Uploaded-files.md) -* [Storage folders](doc/Storage-folders.md) +## 🏗️ Project Structure +``` +my-spin-app/ +├── src/ +│ ├── app/ +│ │ ├── Config/ # JSON configuration files +│ │ ├── Controllers/ # Application controllers +│ │ ├── Middlewares/ # Custom middleware +│ │ ├── Views/ # Template files +│ │ └── Globals.php # Global functions +│ ├── public/ # Web root directory +│ │ ├── bootstrap.php # Application entry point +│ │ └── assets/ # CSS, JS, images +│ └── storage/ # Application storage +│ ├── logs/ # Log files +│ ├── cache/ # Cache files +│ └── database/ # Database files +├── vendor/ # Composer dependencies +├── composer.json # Project dependencies +└── .env # Environment variables +``` -## 3.1. Apache configuration -VHost for running the application under Apache with domain-name recognition. +## 🚀 Getting Started + +### 1. Configuration + +SPIN uses JSON-based configuration files: + +```json +{ + "application": { + "global": { + "maintenance": false, + "timezone": "Europe/Stockholm" + }, + "secret": "${APPLICATION_SECRET}" + }, + "session": { + "cookie": "SID", + "timeout": 3600, + "driver": "apcu" + }, + "logger": { + "level": "notice", + "driver": "php" + } +} +``` -If Port number based applications are desired the `` needs to change to -the corresponding port, and the `domain.name` removed from the config. +### 2. Routing + +Routes are defined in JSON configuration files: + +```json +{ + "common": { + "before": ["\\App\\Middlewares\\RequestIdBeforeMiddleware"], + "after": ["\\App\\Middlewares\\ResponseLogAfterMiddleware"] + }, + "groups": [ + { + "name": "Public API", + "prefix": "/api/v1", + "before": ["\\App\\Middlewares\\CorsBeforeMiddleware"], + "routes": [ + { "methods": ["GET"], "path": "/health", "handler": "\\App\\Controllers\\Api\\HealthController" } + ] + }, + { + "name": "Protected API", + "prefix": "/api/v1", + "before": ["\\App\\Middlewares\\AuthHttpBeforeMiddleware"], + "routes": [ + { "methods": ["GET"], "path": "/users/{id}", "handler": "\\App\\Controllers\\Api\\UserController" } + ] + } + ] +} +``` -```txt - +### 3. Controllers - Define domain.name mydomain.com - Define alias.domain.name www.mydomain.com - Define path_to_root C:/Path/Project - Define environment DEV +Controllers extend SPIN's base classes and use specific HTTP method handlers: - ServerName ${domain.name} - ServerAlias ${alias.domain.name} - ServerAdmin webmaster@${domain.name} +```php + - Header set Cache-Control "public, max-age=604800, must-revalidate" - +class IndexController extends AbstractPlatesController +{ + public function handleGET(array $args) + { + $model = ['title' => 'Welcome to SPIN', 'user' => 'Guest']; + $html = $this->engine->render('pages::index', $model); + return response($html); + } +} +``` - - Options -Indexes +FollowSymLinks - AllowOverride All - Order allow,deny - Allow from all - Require all granted +### 4. Middleware + +Middleware extends `Spin\Core\Middleware`: - # Set Variables - SetEnv ENVIRONMENT ${environment} +```php +secret = config('application.secret'); + return true; + } + + public function handle(array $args): bool + { + $token = getRequest()->getHeaderLine('Authorization'); + if (!$this->validateToken($token)) { + responseJson(['error' => 'Unauthorized'], 401); + return false; + } + return true; + } +} +``` + +## 🔧 Core Features + +### 🛣️ JSON-Based Routing +- **Route Groups** - Organize routes with shared middleware and prefixes +- **HTTP Method Support** - Full support for GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS +- **Dynamic Parameters** - Capture URL parameters with `{paramName}` syntax +- **Middleware Integration** - Apply middleware at common, group, or route level + +### 🔌 Middleware System +- **Common Middleware** - Applied to all requests globally +- **Group Middleware** - Applied to specific route groups +- **Route Middleware** - Applied to individual routes +- **SPIN-Specific** - Uses `initialize()` and `handle()` methods + +### 🗄️ Database Support +- **Multiple Drivers** - MySQL, PostgreSQL, SQLite, CockroachDB, Firebird +- **PDO Based** - Secure, prepared statements by default +- **Connection Management** - Efficient database connection handling +- **JSON Configuration** - Database settings in configuration files + +### 💾 Caching +- **PSR-16 Compatible** - Standard cache interface +- **Multiple Adapters** - APCu, Redis, File-based caching +- **JSON Configuration** - Cache settings in configuration files +- **Performance Optimized** - Minimal overhead for maximum speed + +### 📁 File Management +- **Secure Uploads** - Built-in security and validation +- **Multiple Storage Backends** - Local, cloud, or custom storage +- **File Processing** - Image manipulation, document processing +- **Access Control** - Fine-grained permissions and security + +## 📚 Documentation + +### Core Concepts +- **[Configuration](doc/Configuration.md)** - JSON-based application configuration +- **[Routing & Controllers](doc/Routing.md)** - Learn how to handle HTTP requests +- **[Middleware](doc/Middleware.md)** - Understand the middleware pipeline +- **[Database Operations](doc/Databases.md)** - Working with databases +- **[Caching](doc/Cache.md)** - Implementing efficient caching strategies +- **[File Uploads](doc/Uploaded-files.md)** - Secure file handling +- **[Storage Management](doc/Storage-folders.md)** - Managing application storage + +### Advanced Topics +- **[Security Best Practices](doc/Security.md)** - Security guidelines and implementations +- **[Testing](doc/Testing.md)** - Unit and integration testing +- **[Helpers](doc/Helpers.md)** - Built-in helper functions and utilities + +## 🧪 Testing + +### Run Tests + +```bash +# Windows +.\phpunit.cmd + +# Linux/macOS +./vendor/bin/phpunit + +# With coverage report +./vendor/bin/phpunit --coverage-html coverage/ +``` + +### Test Structure + +``` +tests/ +├── Unit/ # Unit tests +├── Integration/ # Integration tests +├── Feature/ # Feature tests +└── bootstrap.php # Test bootstrap +``` - # Load files in this order on "/" - DirectoryIndex bootstrap.php index.php index.html +## 🌐 Web Server Configuration - # Disable appending a "/" and 301 redirection when a directory - # matches the requested URL - DirectorySlash Off +### Apache Configuration - # Set Rewrite Engine ON to direct all requests to - # the `bootstrap.php` file +```apache + + ServerName mydomain.com + DocumentRoot "/path/to/your/app/src/public" + + + AllowOverride All + Require all granted + RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-f @@ -125,39 +286,95 @@ the corresponding port, and the `domain.name` removed from the config. ``` -## 3.2. Nginx configuration +### Nginx Configuration -```txt +```nginx server { listen 80; - server_name mydomain.com www.mydomain.com; - - root C:/Path/Project/src/public; # Nginx on Windows still uses forward slashes - index bootstrap.php index.php index.html; - - access_log logs/mydomain.com.access.log; - error_log logs/mydomain.com.error.log; - - # Set environment variable for PHP-FPM - fastcgi_param ENVIRONMENT DEV; - - # Default caching headers for static content - location ~* \.(ico|pdf|flv|jpg|jpeg|png|gif|js|css|swf)$ { - add_header Cache-Control "public, max-age=604800, must-revalidate"; - try_files $uri =404; - } - - # Deny directory listings + server_name mydomain.com; + root /path/to/your/app/src/public; + index bootstrap.php; + location / { try_files $uri $uri/ /bootstrap.php?$query_string; } - - # PHP handling (adjust the socket/path to your PHP-FPM setup) + location ~ \.php$ { - include fastcgi_params; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - fastcgi_pass 127.0.0.1:9000; # or unix:/var/run/php/php8.1-fpm.sock + fastcgi_pass 127.0.0.1:9000; fastcgi_index bootstrap.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; } } ``` + +## 🔌 PSR Standards Support + +SPIN Framework is built on PSR standards for maximum compatibility: + +- **PSR-3** - Logger Interface (Monolog by default) +- **PSR-7** - HTTP Message Interface (Guzzle by default) +- **PSR-11** - Container Interface (League Container by default) +- **PSR-15** - HTTP Middleware Interface +- **PSR-16** - Simple Cache Interface +- **PSR-17** - HTTP Factory Interface + +## 🚀 Performance Features + +- **Lazy Loading** - Components loaded only when needed +- **Memory Management** - Efficient memory usage and garbage collection +- **Connection Pooling** - Optimized database connections +- **Smart Caching** - Intelligent cache invalidation and management +- **Compiled Routes** - Fast route matching and resolution + +## 🔒 Security Features + +- **CSRF Protection** - Built-in cross-site request forgery protection +- **SQL Injection Prevention** - PDO prepared statements by default +- **XSS Protection** - Automatic output escaping +- **File Upload Security** - Secure file handling and validation +- **Input Validation** - Comprehensive input sanitization +- **JWT Support** - Built-in JWT token handling +- **Rate Limiting** - Built-in request rate limiting + +## 🌟 Community & Support + +### Getting Help + +- **Documentation**: [https://github.com/Celarius/spin-framework](https://github.com/Celarius/spin-framework) +- **Issues**: [GitHub Issues](https://github.com/Celarius/spin-framework/issues) +- **Discussions**: [GitHub Discussions](https://github.com/Celarius/spin-framework/discussions) + +### Contributing + +We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests +5. Submit a pull request + +### Code of Conduct + +Please read our [Code of Conduct](CODE_OF_CONDUCT.md) to keep our community approachable and respectable. + +## 📄 License + +SPIN Framework is open-sourced software licensed under the [MIT License](LICENSE). + +## 🙏 Acknowledgments + +- Built with ❤️ by the SPIN Framework Team +- Inspired by modern PHP frameworks and PSR standards +- Special thanks to all contributors and the PHP community + +## 📊 Statistics + +- **Downloads**: [![Total Downloads](https://poser.pugx.org/celarius/spin-framework/downloads)](https://packagist.org/packages/celarius/spin-framework) +- **Version**: [![Latest Stable Version](https://poser.pugx.org/celarius/spin-framework/v/stable)](https://packagist.org/packages/celarius/spin-framework) +- **Tests**: [![Unit Tests](https://github.com/Celarius/spin-framework/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/Celarius/spin-framework/actions/workflows/unit-tests.yml) + +--- + +**Ready to build something amazing?** Start with SPIN Framework today and experience the joy of lightweight, fast PHP development! 🚀 diff --git a/src/Application.php b/src/Application.php index f6d3733..fabd4f4 100644 --- a/src/Application.php +++ b/src/Application.php @@ -3,7 +3,14 @@ /** * Spin Application Class * + * Main application class that orchestrates the framework lifecycle including + * routing, middleware execution, error handling, and dependency management. + * Provides the core application container and coordinates all framework + * components. + * * @package Spin + * @author Spin Framework Team + * @since 1.0.0 */ namespace Spin; @@ -24,7 +31,9 @@ use \Spin\Exceptions\SpinException; use \Spin\Classes\RequestIdClass; - +/** + * Main Application Class + */ class Application extends AbstractBaseClass implements ApplicationInterface { /** @@ -196,11 +205,17 @@ class Application extends AbstractBaseClass implements ApplicationInterface protected array $globalVars; +############################################################################################################# +############################################################################################################# +############################################################################################################# + + /** * Constructor * * @param string $basePath The base path to the application folder - * @throws Exception + * + * @throws Exception If initialization fails */ public function __construct(string $basePath) { @@ -221,8 +236,8 @@ public function __construct(string $basePath) # Set paths $this->basePath = \realpath($basePath); - $this->appPath = $this->basePath . '/app'; - $this->storagePath = $this->basePath . '/storage'; + $this->appPath = $this->basePath . \DIRECTORY_SEPARATOR . 'app'; + $this->storagePath = $this->basePath . \DIRECTORY_SEPARATOR . 'storage'; # Create config $this->config = new Config($this->appPath, $this->getEnvironment()); @@ -305,14 +320,6 @@ public function __construct(string $basePath) } } - /** - * Run the application - * - * @param ?array $serverRequest Optional array with server request variables like $_SERVER - * - * @return bool True if application ran successfully - * @throws SpinException - */ public function run(?array $serverRequest = []): bool { # Check and Report on config variables @@ -340,9 +347,6 @@ public function run(?array $serverRequest = []): bool */ protected function checkAndReportConfigVars(): void { - ## - ## Perform checks on some variables - ## if (!\is_dir($this->sharedStoragePath)) { # Attempt to create it $ok = \mkdir($this->sharedStoragePath, 0777, true); @@ -563,10 +567,6 @@ protected function runRoute(): ?Response } } - # Run After Hooks - // $ok = $this->runHooks('OnAfterRequest'); - - # Return the generated response return $this->getResponse(); } // if count() ... @@ -591,9 +591,6 @@ protected function runRoute(): ?Response return $this->runErrorController('', 404); } - /** - * @inheritDoc - */ public function runErrorController(string $body, int $httpCode = 400): Response { $class = ''; @@ -624,11 +621,11 @@ public function runErrorController(string $body, int $httpCode = 400): Response # Run Controller's handler return $routeHandler->$handlerMethod([]); } - \logger()->error('Failed to create error controller',[ + \logger()?->error('Failed to create error controller',[ 'class'=>$handlerClass ]); } else { - \logger()->notice('Error controller class does not exist',[ + \logger()?->notice('Error controller class does not exist',[ 'class'=>$handlerClass, 'httpCode'=>$httpCode ]); @@ -687,10 +684,7 @@ protected function setErrorHandlers(): bool return true; } - /** - * @inheritDoc - */ - public function errorHandler(int $errNo, $errStr, $errFile, $errLine, array $errContext = []): bool + public function errorHandler($errNo, $errStr, $errFile, $errLine, array $errContext = []) { if (!(\error_reporting())) { // This error code is not included in error_reporting, so let it fall @@ -699,7 +693,7 @@ public function errorHandler(int $errNo, $errStr, $errFile, $errLine, array $err return false; } - switch ($errNo) { + switch ((int)$errNo) { # Error case E_ERROR: case E_USER_ERROR: @@ -734,9 +728,6 @@ public function errorHandler(int $errNo, $errStr, $errFile, $errLine, array $err return true; } - /** - * @inheritDoc - */ public function exceptionHandler($exception) { if (!\is_null($this->getResponse())) { @@ -759,15 +750,6 @@ public function exceptionHandler($exception) return null; } - /** - * PHP Fatal Error Handler - * - * Handles any PHP Fatal Errors. - * - * This includes "maximum timeout", "out of memory", "undefined variable" situations. - * - * @return bool True if handled - */ public function fatalErrorhandler(): bool { # Get last PHP error @@ -798,21 +780,6 @@ public function fatalErrorhandler(): bool return true; } - /** - * Set a cookie for the next response - * - * Defaults to setting 'samesite'='Strict' to prevent CSRF. - * - * @param string $name The cookie name - * @param string $value The cookie value - * @param int $expire The cookie expiration time - * @param string $path The cookie path - * @param string $domain The cookie domain - * @param bool $secure The cookie secure flag - * @param bool $httpOnly The cookie httpOnly flag - * - * @return bool - */ public function setCookie(string $name, string $value = '', int $expire = 0, @@ -841,65 +808,35 @@ public function setCookie(string $name, return true; } - /** - * getBasePath returns the full path to the application root folder - * - * @return string The base path - */ public function getBasePath(): string { return $this->basePath; } - /** - * getAppPath returns the full path to the application folder + "/app" - * - * @return string The app path - */ public function getAppPath(): string { return $this->appPath; } - /** - * getConfigPath returns the full path to the application folder + "/app/Config" - * - * @return string The config path - */ public function getConfigPath(): string { return $this->appPath . \DIRECTORY_SEPARATOR . 'Config'; } - /** - * getStoragePath returns the full path to the application folder + "/storage" - * - * @return string The storage path - */ public function getStoragePath(): string { return $this->storagePath; } - /** - * getSharedStoragePath returns the full path to the configured shared storage path. - * If the config does not contain an entry for the shared storage, the result is the same - * as `getStoragePath()` - * - * @return string The shared storage path - */ public function getSharedStoragePath(): string { if (empty($this->sharedStoragePath)) { - return $this->getStoragepath(); + return $this->getStoragePath(); } return $this->sharedStoragePath; } - /** - * @inheritDoc - */ public function getProperty(string $property): mixed { if (\property_exists(__CLASS__, $property)) { @@ -909,139 +846,74 @@ public function getProperty(string $property): mixed return $this->container($property); } - /** - * Get Application Name - from config-*.json - * - * @return string The application name - */ public function getAppName(): string { return $this->version['application']['name'] ?? ''; } - /** - * Get Application Code - from config-*.json - * - * @return string The application code - */ public function getAppCode(): string { return $this->version['application']['code'] ?? ''; } - /** - * Get Application Version - from config-*.json - * - * @return string The application version - */ public function getAppVersion(): string { return $this->version['application']['version'] ?? ''; } - /** - * Get the HTTP Request (ServerRequest) - * - * @return Request The request object - */ public function getRequest(): Request { return $this->request; } - /** - * Get the HTTP Response (ServerResponse) - * - * @return Response The response object - */ public function getResponse(): Response { return $this->response; } - /** - * Set the HTTP Response (ServerResponse) - * - * @param Response $response The response object - * - * @return self The current object - */ - public function setResponse(Response $response): self + + public function setResponse($response): self { $this->response = $response; return $this; } - /** - * Get the Config object - * - * @return ?Config The config object - */ + public function getConfig(): ?Config { return $this->config; } - /** - * Get the PSR-3 Logger object - * - * @return Logger The logger object - */ + public function getLogger(): Logger { return $this->logger; } - /** - * Get the PSR-11 Container object - * - * @return mixed The container object - */ + public function getContainer(): mixed { return $this->container; } - /** - * Get the DB Manager - * - * @return ConnectionManager The connection manager - */ + public function getConnectionManager(): ConnectionManager { return $this->connectionManager; } - /** - * Get the Cache Object via CacheManager - * - * @param string $driverName The driver name - * - * @return ?AbstractCacheAdapterInterface The cache object - */ + public function getCache(string $driverName = ''): ?AbstractCacheAdapterInterface { return $this->cacheManager->getCache($driverName); } - /** - * Get the Environment as set in ENV vars - * - * @return string The environment - */ public function getEnvironment(): string { return $this->environment; } - /** - * Set the Environment where app is running - * - * @param string $environment The environment name - * - * @return self The current object - */ public function setEnvironment(string $environment): self { $this->environment = \strtolower($environment); @@ -1049,13 +921,6 @@ public function setEnvironment(string $environment): self return $this; } - /** - * Get a RouteGroup by Name - * - * @param string $groupName The group name - * - * @return null|RouteGroup `null` if not found or The route group - */ public function getRouteGroup(string $groupName): ?RouteGroup { foreach ($this->routeGroups as $routeGroup) { @@ -1067,24 +932,11 @@ public function getRouteGroup(string $groupName): ?RouteGroup return null; } - /** - * Get all RouteGroups - * - * @return array The route groups - */ public function getRouteGroups(): array { return $this->routeGroups; } - /** - * Get or Set a Container value. - * - * @param string $name Dependency name to get or set - * @param mixed $value Value to SET - * - * @return mixed `null` if not found or the value for `$name` in the container - */ public function container(string $name, $value=null): mixed { # Getting or Setting the value? @@ -1098,25 +950,17 @@ public function container(string $name, $value=null): mixed } elseif (\is_callable($value)) { # Callable - $this->getContainer()->addShared($name,$value); + $this->getContainer()->addShared($name, $value); } else { # Variable - $this->getContainer()->addShared($name,$value); + $this->getContainer()->addShared($name, $value); } return $value; } - /** - * Set the file to send as response - * - * @param string $filename Filename to send - * @param bool $remove Optional. Default `false`. Set to `True` to remove the file after sending - * - * @return self The current object - */ public function setFileResponse(string $filename, bool $remove = false): self { $this->responseFile = $filename; @@ -1125,11 +969,6 @@ public function setFileResponse(string $filename, bool $remove = false): self return $this; } - /** - * Send Response back to client - * - * @return self The current object - */ public function sendResponse(): self { # Set HTTP Response Code @@ -1223,41 +1062,21 @@ public function sendResponse(): self return $this; } - /** - * Get the UploadedFilesManager - * - * @return UploadedFilesManager The uploaded files manager - */ public function getUploadedFilesManager(): UploadedFilesManager { return $this->uploadedFilesManager; } - /** - * Get the value of initialMemUsage - * - * @return int The initial memory usage - */ public function getInitialMemUsage(): int { return $this->initialMemUsage; } - /** - * Get the value of globalVars - * - * @return array The global vars - */ public function getGlobalVars(): array { return $this->globalVars; } - /** - * Set the value of globalVars - * - * @return self The current object - */ public function setGlobalVars($globalVars): self { $this->globalVars = $globalVars; @@ -1265,26 +1084,11 @@ public function setGlobalVars($globalVars): self return $this; } - /** - * Get one global var - * - * @param string $id The global variable id - * - * @return null|mixed The global variable - */ public function getGlobalVar(string $id): mixed { return $this->globalVars[$id] ?? null; } - /** - * Set one global var - * - * @param string $id The global variable id - * @param mixed $value The global variable value - * - * @return self The current object - */ public function setGlobalVar(string $id, mixed $value): self { $this->globalVars[$id] = $value; diff --git a/src/ApplicationInterface.php b/src/ApplicationInterface.php index a464b3d..d23bf06 100644 --- a/src/ApplicationInterface.php +++ b/src/ApplicationInterface.php @@ -1,9 +1,15 @@ The global vars + */ + public function getGlobalVars(): array; + + /** + * Set globalVars + * + * @param array $globalVars The global vars + * + * @return self Self + */ + public function setGlobalVars($globalVars): self; + + /** + * Get one global var + * + * @param string $id The global variable id + * + * @return null|mixed The global variable + */ + public function getGlobalVar(string $id): mixed; + + /** + * Set one global var + * + * @param string $id The global variable id + * @param mixed $value The global variable value + * + * @return self Self + */ + public function setGlobalVar(string $id, mixed $value): self; + } diff --git a/src/Cache/AbstractCacheAdapter.php b/src/Cache/AbstractCacheAdapter.php index 0ff4025..814c7b3 100644 --- a/src/Cache/AbstractCacheAdapter.php +++ b/src/Cache/AbstractCacheAdapter.php @@ -1,9 +1,15 @@ redisClient->isConnected(); } + /** + * @inheritDoc + */ public function get($key, mixed $default = null): mixed { $result = $this->redisClient->get($key); @@ -94,14 +99,19 @@ public function get($key, mixed $default = null): mixed /** * Returns raw values from Redis without unserializing the data. - * If an object needs to be unserialized against its original class - * the client should handle it. + * + * @param string|int $key + * @param mixed $default + * @return mixed */ public function getRaw($key, mixed $default = null): mixed { return $this->redisClient->get($key) ?? $default; } + /** + * @inheritDoc + */ public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool { if (!\is_int($value)) { @@ -118,16 +128,25 @@ public function set(string $key, mixed $value, \DateInterval|int|null $ttl = nul return (bool)$this->redisClient->set($key, $value, 'ex', $ttl); } + /** + * @inheritDoc + */ public function delete($key): bool { return $this->redisClient->del($key) !== 0; } + /** + * @inheritDoc + */ public function clear(): bool { return true; } + /** + * @inheritDoc + */ public function getMultiple($keys, mixed $default = null): iterable { $values = array(); @@ -138,6 +157,9 @@ public function getMultiple($keys, mixed $default = null): iterable return $values; } + /** + * @inheritDoc + */ public function setMultiple($values, \DateInterval|int|null $ttl = null): bool { foreach ($values as $key=>$value) { @@ -147,6 +169,9 @@ public function setMultiple($values, \DateInterval|int|null $ttl = null): bool return true; } + /** + * @inheritDoc + */ public function deleteMultiple(iterable $keys): bool { foreach ($keys as $key) { @@ -156,21 +181,33 @@ public function deleteMultiple(iterable $keys): bool return true; } + /** + * @inheritDoc + */ public function has(string $key): bool { return $this->redisClient->exists($key) !== 0; } + /** + * @inheritDoc + */ public function inc(string $key, int $amount = 1): bool|int { return $this->redisClient->incrby($key, $amount); } + /** + * @inheritDoc + */ public function dec(string $key, int $amount = 1): bool|int { return $this->redisClient->decrby($key, $amount); } + /** + * @inheritDoc + */ public function statistics(): array { return $this->redisClient->info(); diff --git a/src/Classes/RequestIdClass.php b/src/Classes/RequestIdClass.php index f6c0559..d4544f7 100644 --- a/src/Classes/RequestIdClass.php +++ b/src/Classes/RequestIdClass.php @@ -18,7 +18,7 @@ class RequestIdClass * * @var string */ - protected $id = ''; + protected string $id = ''; /** * Constructor. diff --git a/src/Core/AbstractBaseClass.php b/src/Core/AbstractBaseClass.php index c7358ec..c648615 100644 --- a/src/Core/AbstractBaseClass.php +++ b/src/Core/AbstractBaseClass.php @@ -1,17 +1,23 @@ findCache($name); + + if (\is_null($cache)) { + # Attempt to create the cache + $cache = $this->createCache($name); + + if (!\is_null($cache)) { + $this->addCache($cache); + } + } + + return $cache; + } + + /** + * Find a Cache based on name + * + * If the $name is empty/null we'll return the 1st cache in the internal list + * (if there is one) + * + * @param string $name Name of the cache (from Config) + * + * @return null|AbstractCacheAdapter + */ + public function findCache(string $name=''): ?AbstractCacheAdapter { - # Find the cache (if we already have it created) - $cache = $this->findCache($name); - - if (\is_null($cache)) { - # Attempt to create the cache - $cache = $this->createCache($name); - - if (!\is_null($cache)) { - $this->addCache($cache); - } - } - - return $cache; - } - - /** - * Find a Cache based on name - * - * If the $name is empty/null we'll return the 1st cache in the internal list - * (if there is one) - * - * @param string $name Name of the cache (from Config) - * - * @return null|AbstractCacheAdapter - */ - public function findCache(string $name='') - { - if ( empty($name) ) { - # Take first available - $cache = \reset($this->caches); - if ($cache === false) + if (empty($name)) { + # Take first available + $cache = \reset($this->caches); + if ($cache === false) { return null; - } else { - # Attempt to find the cache from the list - $cache = ( $this->caches[\strtolower($name)] ?? null); - } - - return $cache; - } - - /** - * Adds the Cache to the Pool - * - * @param AbstractCacheAdapterInterface $cache [description] - * - * @return self - */ - public function addCache(AbstractCacheAdapterInterface $cache) + } + } else { + # Attempt to find the cache from the list + $cache = ( $this->caches[\strtolower($name)] ?? null); + } + + return $cache; + } + + /** + * Adds the Cache to the Pool + * + * @param AbstractCacheAdapterInterface $cache [description] + * + * @return self + */ + public function addCache(AbstractCacheAdapterInterface $cache): self { - $this->caches[\strtolower($cache->getDriver())] = $cache; - - return $this; - } - - /** - * Remove a cache from the pool - * - * @param string $name The name of cache to remove - * - * @return self - */ - public function removeCache(string $name) + $this->caches[\strtolower($cache->getDriver())] = $cache; + + return $this; + } + + /** + * Remove a cache from the pool + * + * @param string $name The name of cache to remove + * + * @return self + */ + public function removeCache(string $name): self { - # Sanity check - if (empty($name)) return $this; - - $cache = $this->findCache($name); - - if ($cache) { - unset( $this->caches[\strtolower($cache->getDriver())] ); - unset($cache); - $cache = null; + # Sanity check + if (empty($name)) { + return $this; } - return $this; - } - - /** - * Creates a cache based on the $name - * - * Finds the corresponding name in the config and uses it to instanciate a - * cache. If the $name is empty, we will use the 1st available in the caches - * list. - * - * @param string $name [description] - * @return null|AbstractCacheAdapter - */ - protected function createCache(string $name) + $cache = $this->findCache($name); + + if ($cache) { + unset($this->caches[\strtolower($cache->getDriver())]); + unset($cache); + $cache = null; + } + + return $this; + } + + /** + * Creates a cache based on the $name + * + * Finds the corresponding name in the config and uses it to instanciate a + * cache. If the $name is empty, we will use the 1st available in the caches + * list. + * + * @param string $name [description] + * @return null|AbstractCacheAdapter + */ + protected function createCache(string $name): ?AbstractCacheAdapter { - # Try to find the connection in the internal list, if it was created already - $cache = $this->caches[\strtolower($name)] ?? null; - - # If no connection found, and the $name is empty, read in 1st one - if (\is_null($cache) && empty($name)) { - # Get caches from conf - $arr = \config()->get('caches'); - if ($arr) { - \reset($arr); - # Take the 1st caches name - $name = \key($arr); - } - } - - if (\is_null($cache)) { - # Get connection's params from conf - $conf = \config()->get('caches.'.$name); - - # Instantiate either based on CLASS or the ADAPTER name - if (isset($conf['class']) && !empty($conf['class'])) { - $className = $conf['class']; - } else { - $className = '\\Spin\\Cache\\Adapters\\'.\ucfirst($conf['adapter'] ?? '') ; - } - - # Create the Cache - try { - if (\class_exists($className)) { - $cache = new $className($conf); - \logger()->debug( 'Created Cache',[ - 'adapter'=>$cache->getDriver(), - 'version'=>$cache->getVersion() - ]); - } else { - \logger()->error( 'Cache class does not exist',[ - 'name'=>$name, - 'classname'=>$className, - 'config'=>$conf - ]); - } - - } catch (\Exception $e) { - \logger()->critical( $e->getMessage(),[ - 'trace'=>$e->getTraceAsString() - ]); - } - } - - return $cache; - } - - /** - * Get array of containers - * - * @return array - */ - public function getCaches(): array - { - return $this->caches; - } - + # Try to find the connection in the internal list, if it was created already + $cache = $this->caches[\strtolower($name)] ?? null; + + # If no connection found, and the $name is empty, read in 1st one + if (\is_null($cache) && empty($name)) { + # Get caches from conf + $arr = \config()->get('caches'); + if ($arr) { + \reset($arr); + # Take the 1st caches name + $name = \key($arr); + } + } + + if (\is_null($cache)) { + # Get connection's params from conf + $conf = \config()->get('caches.'.$name); + + # Instantiate either based on CLASS or the ADAPTER name + if (!empty($conf['class'])) { + $className = $conf['class']; + } else { + $className = '\\Spin\\Cache\\Adapters\\'.\ucfirst($conf['adapter'] ?? ''); + } + + # Create the Cache + try { + if (\class_exists($className)) { + $cache = new $className($conf); + \logger()?->debug( 'Created Cache',[ + 'adapter'=>$cache->getDriver(), + 'version'=>$cache->getVersion() + ]); + } else { + \logger()?->error( 'Cache class does not exist',[ + 'name'=>$name, + 'classname'=>$className, + 'config'=>$conf + ]); + } + + } catch (\Exception $e) { + \logger()?->critical( $e->getMessage(),[ + 'trace'=>$e->getTraceAsString() + ]); + } + } + + return $cache; + } + + /** + * @return array + */ + public function getCaches(): array + { + return $this->caches; + } } diff --git a/src/Core/CacheManagerInterface.php b/src/Core/CacheManagerInterface.php index cc7ca9d..a3ccea5 100644 --- a/src/Core/CacheManagerInterface.php +++ b/src/Core/CacheManagerInterface.php @@ -1,9 +1,15 @@ + */ + protected array $confValues = []; - /** @var string Config file name */ + /** + * Config file name + * @var string + */ protected string $filename; + /** * Constructor * * Load config file based on $appPath and $environment * - * @param string $appPath Path to the /app folder - * @param string $environment Name of the environment - * @throws Exception + * @param string $appPath Path to the /app folder + * @param string $environment Name of the environment + * + * @throws Exception */ public function __construct(string $appPath, string $environment) { @@ -40,6 +58,13 @@ public function __construct(string $appPath, string $environment) $this->load($filename); } + /** + * Destructor + */ + public function __destruct() + { + $this->clear(); + } /** * Clear all config values @@ -48,7 +73,7 @@ public function __construct(string $appPath, string $environment) */ public function clear(): self { - $this->confValues = array(); + $this->confValues = []; return $this; } @@ -77,7 +102,7 @@ public function load(string $filename): self } if ($configArray) { - $this->confValues = $configArray; + $this->confValues = $this->replaceEnvMacros($configArray); } else { throw new SpinException('Invalid JSON file "' . $filename . '"'); } @@ -110,7 +135,7 @@ public function loadAndMerge(string $filename): self if ($configArray) { # Merge the Config with existing config - $this->confValues = \array_replace_recursive($this->confValues, $configArray); + $this->confValues = $this->replaceEnvMacros(\array_replace_recursive($this->confValues, $configArray)); } else { throw new SpinException('Invalid JSON file "' . $filename . '"'); } @@ -258,4 +283,40 @@ protected function array_change_key_case_recursive(array $input, int $case = \CA return $input; } + /** + * Requirsively replaces `${env:}` with the environment variabe . + * + * Missing environment variables are replaced with an empty string. + * + * note: Environment variable names are case-sensitive on Unix-like systems but aren't case-sensitive on Windows + * + * @param array $input Config array to process + * @param array|null $envVars Optional Environment variables to use for replacement, KEY=VALUE pairs + * + * @return array Processed config array + */ + protected function replaceEnvMacros(array $input, ?array $envVars=null): array + { + if ($envVars === null) { + // Get all env vars into array + $envVars = array_merge($_ENV, getenv()); + } + + foreach ($input as $key => $value) { + if (\is_array($value)) { + # Recurse into sub-array + $input[$key] = $this->replaceEnvMacros($value, $envVars); + } elseif (\is_string($value)) { + # Replace all `${env:}` with the environment variable value + $input[$key] = \preg_replace_callback( + '/\$\{env:([A-Za-z0-9_]+)\}/', + function ($matches) use ($envVars) { + $envVarName = $matches[1]; + return $envVars[$envVarName] ?? ''; + },$value); + } + } + + return $input; + } } diff --git a/src/Core/ConfigInterface.php b/src/Core/ConfigInterface.php index f75e6ff..0c64eb9 100644 --- a/src/Core/ConfigInterface.php +++ b/src/Core/ConfigInterface.php @@ -47,7 +47,7 @@ function save(string $filename = ''): bool; /** * Get a config item * - * @param string $key "." notationed key to retreive + * @param string $key "." notationed key to retrieve * @param mixed $default Optional Default value if group::section::key * not found * @@ -58,7 +58,7 @@ function get(string $key, $default=null); /** * Set a config item * - * @param string $key "." notationed key to retreive + * @param string $key "." notationed key to retrieve * @param mixed $value Value to set * * @return self diff --git a/src/Core/ConnectionManager.php b/src/Core/ConnectionManager.php index 9db99d1..95d657b 100644 --- a/src/Core/ConnectionManager.php +++ b/src/Core/ConnectionManager.php @@ -1,11 +1,15 @@ connections; } - } diff --git a/src/Core/ConnectionManagerInterface.php b/src/Core/ConnectionManagerInterface.php index 3238af1..d54dad6 100644 --- a/src/Core/ConnectionManagerInterface.php +++ b/src/Core/ConnectionManagerInterface.php @@ -5,6 +5,17 @@ use \Spin\Database\PdoConnection; use \Spin\Database\PdoConnectionInterface; +/** + * Database Connection Manager Interface + * + * Defines the contract for database connection management operations including + * connection resolution, creation, pooling, and lifecycle management. Implemented + * by ConnectionManager to provide centralized database connection administration. + * + * @package Spin\Core + * @author Spin Framework Team + * @since 1.0.0 + */ interface ConnectionManagerInterface { /** diff --git a/src/Core/Controller.php b/src/Core/Controller.php index 0dc9aaf..0219492 100644 --- a/src/Core/Controller.php +++ b/src/Core/Controller.php @@ -1,9 +1,16 @@ run() - */ - public function handle(array $args) - { - switch ( \strtoupper(getRequest()->getMethod()) ) { - case "GET" : return $this->handleGET($args); - case "POST" : return $this->handlePOST($args); - case "PUT" : return $this->handlePUT($args); - case "PATCH" : return $this->handlePATCH($args); - case "DELETE" : return $this->handleDELETE($args); - case "HEAD" : return $this->handleHEAD($args); - case "OPTIONS": return $this->handleOPTIONS($args); - default : return $this->handleCUSTOM($args); - } - } - - /** - * Handle GET request - * - * @param array $args Path variable arguments as name=value pairs - * - * @return Response Value returned by $app->run() - */ - public function handleGET(array $args) - { - return \response('',405); - } - - /** - * Handle POST request - * - * @param array $args Path variable arguments as name=value pairs - * - * @return Response Value returned by $app->run() - */ - public function handlePOST(array $args) - { - return \response('',405); - } - - /** - * Handle PUT request - * - * @param array $args Path variable arguments as name=value pairs - * - * @return Response Value returned by $app->run() - */ - public function handlePUT(array $args) - { - return \response('',405); - } - - /** - * Handle PATCH request - * - * @param array $args Path variable arguments as name=value pairs - * - * @return Response Value returned by $app->run() - */ - public function handlePATCH(array $args) - { - return \response('',405); - } - - /** - * Handle DELETE request - * - * @param array $args Path variable arguments as name=value pairs - * - * @return Response Value returned by $app->run() - */ - public function handleDELETE(array $args) - { - return \response('',405); - } - - /** - * Handle HEAD request - * - * @param array $args Path variable arguments as name=value pairs - * - * @return Response Value returned by $app->run() - */ - public function handleHEAD(array $args) - { - return \response('',405); - } - - /** - * Handle OPTIONS request - * - * @param array $args Path variable arguments as name=value pairs - * - * @return Response Value returned by $app->run() - */ - public function handleOPTIONS(array $args) - { - return \response('',405); - } - - /** - * Handle custom request - * - * @param array $args Path variable arguments as name=value pairs - * - * @return Response Value returned by $app->run() - */ - public function handleCUSTOM(array $args) - { - return \response('',405); - } - - /** - * Return the Client HTTP Request object - * - * @return object - */ - public function getRequest() - { - return \getRequest(); - } - - /** - * Return the Client HTTP Response object - * - * @return object - */ - public function getResponse() - { - return \getResponse(); - } - - /** - * Return the Config object - * - * @return object - */ - public function getConfig() - { - return \config(); - } - - /** - * Return the Logger object - * - * @return object - */ - public function getLogger() - { - return \logger(); - } - - /** - * Return the Cache object - * - * @return object - */ - public function getCache() - { - return \cache(); - } - + /** + * Constructor + */ + public function __construct() + { + parent::__construct(); + } + + /** + * Initialization method + * + * This method is called right after the object has been created before any + * route specific Middleware handlers + * + * @param array $args Path variable arguments as name=value pairs + */ + public function initialize(array $args) + { + } + + /** + * Default handle() method for all HTTP Methods. + * + * Calls the appropriate handle*() method. + * + * @param array $args Path variable arguments as name=value pairs + * + * @return Response Value returned by $app->run() + */ + public function handle(array $args) + { + switch ( \strtoupper(getRequest()->getMethod()) ) { + case "GET" : return $this->handleGET($args); + case "POST" : return $this->handlePOST($args); + case "PUT" : return $this->handlePUT($args); + case "PATCH" : return $this->handlePATCH($args); + case "DELETE" : return $this->handleDELETE($args); + case "HEAD" : return $this->handleHEAD($args); + case "OPTIONS": return $this->handleOPTIONS($args); + default : return $this->handleCUSTOM($args); + } + } + + /** + * Handle GET request + * + * @param array $args Path variable arguments as name=value pairs + * + * @return Response Value returned by $app->run() + */ + public function handleGET(array $args) + { + return \response('',405); + } + + /** + * Handle POST request + * + * @param array $args Path variable arguments as name=value pairs + * + * @return Response Value returned by $app->run() + */ + public function handlePOST(array $args) + { + return \response('',405); + } + + /** + * Handle PUT request + * + * @param array $args Path variable arguments as name=value pairs + * + * @return Response Value returned by $app->run() + */ + public function handlePUT(array $args) + { + return \response('',405); + } + + /** + * Handle PATCH request + * + * @param array $args Path variable arguments as name=value pairs + * + * @return Response Value returned by $app->run() + */ + public function handlePATCH(array $args) + { + return \response('',405); + } + + /** + * Handle DELETE request + * + * @param array $args Path variable arguments as name=value pairs + * + * @return Response Value returned by $app->run() + */ + public function handleDELETE(array $args) + { + return \response('',405); + } + + /** + * Handle HEAD request + * + * @param array $args Path variable arguments as name=value pairs + * + * @return Response Value returned by $app->run() + */ + public function handleHEAD(array $args) + { + return \response('',405); + } + + /** + * Handle OPTIONS request + * + * @param array $args Path variable arguments as name=value pairs + * + * @return Response Value returned by $app->run() + */ + public function handleOPTIONS(array $args) + { + return \response('',405); + } + + /** + * Handle custom request + * + * @param array $args Path variable arguments as name=value pairs + * + * @return Response Value returned by $app->run() + */ + public function handleCUSTOM(array $args) + { + return \response('',405); + } + + /** + * Return the Client HTTP Request object + * + * @return object + */ + public function getRequest() + { + return \getRequest(); + } + + /** + * Return the Client HTTP Response object + * + * @return object + */ + public function getResponse() + { + return \getResponse(); + } + + /** + * Return the Config object + * + * @return object + */ + public function getConfig() + { + return \config(); + } + + /** + * Return the Logger object + * + * @return object + */ + public function getLogger() + { + return \logger(); + } + + /** + * Return the Cache object + * + * @return object + */ + public function getCache() + { + return \cache(); + } } diff --git a/src/Core/ControllerInterface.php b/src/Core/ControllerInterface.php index 159771e..7c46727 100644 --- a/src/Core/ControllerInterface.php +++ b/src/Core/ControllerInterface.php @@ -1,9 +1,15 @@ name; - } - - /** - * Set Hook name - * - * @param string $name [description] - * - * @return self - */ - public function setName(string $name) - { - $this->name = $name; - - return $this; - } -} diff --git a/src/Core/HookInterface.php b/src/Core/HookInterface.php deleted file mode 100644 index a2adf1d..0000000 --- a/src/Core/HookInterface.php +++ /dev/null @@ -1,37 +0,0 @@ -hooks = []; - } - - /** - * Get a Hook Object by $name - * - * @param string $name Hook Name - * - * @return null|Hook - */ - public function getHook(string $name): ?Hook - { - # Find hook in list - foreach ($this->hooks as $hook) - { - if ( \strcasecmp($name, $hook->getName())==0 ) { - return $hook; - } - } - - return null; - } - - /** - * Add a Hook by $name - * - * @param mixed $hook - * - * @return self - */ - public function addHook(Hook $hook) - { - # Attempt to find it first - $exists = $this->getHook($hook->getName()); - - # If it exists, return with null - if (!\is_null($exists)) { - return $this; - } - - # Add it to the list - $this->hooks[$hook->getName()]; - - return $this; - } - - /** - * Remove a Hook by $name - * - * @param string $name Hook Name - * - * @return self - */ - public function removeHook(string $name) - { - foreach ($this->hooks as $idx => $hook) - { - if ( \strcasecmp($name, $hook->getName())==0 ) { - # Remove it - unset( $this->hooks[$idx] ); - - return $this; - } - } - - return $this; - } - -} diff --git a/src/Core/HookManagerInterface.php b/src/Core/HookManagerInterface.php deleted file mode 100644 index 8b641f3..0000000 --- a/src/Core/HookManagerInterface.php +++ /dev/null @@ -1,39 +0,0 @@ -handler = $handler; } - public function getMethod() + /** + * Get HTTP method + * + * @return string + */ + public function getMethod(): string { return $this->method; } - public function getPath() + /** + * Get URI path + * + * @return string + */ + public function getPath(): string { return $this->path; } - public function getHandler() + /** + * Get handler class name + * + * @return string + */ + public function getHandler(): string { return $this->handler; } - } diff --git a/src/Core/RouteGroup.php b/src/Core/RouteGroup.php index 5b9d6f1..6e3aa2b 100644 --- a/src/Core/RouteGroup.php +++ b/src/Core/RouteGroup.php @@ -1,9 +1,15 @@ name = $definition['name'] ?? ''; - $this->prefix = $definition['prefix'] ?? ''; - $this->beforeMiddleware = $definition['before'] ?? []; - $this->routes = $definition['routes'] ?? []; - $this->afterMiddleware = $definition['after'] ?? []; - - # Route Parser, dataGenrator & RouteCollector - $routeParser = new \FastRoute\RouteParser\Std(); - $dataGenerator = new \FastRoute\DataGenerator\GroupCountBased(); - $this->routeCollector = new \FastRoute\RouteCollector($routeParser,$dataGenerator); - - # Add the Routes - foreach ($this->routes as $route) - { - # Method extraction - - # Default to ALL - $methods = ['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS']; - - if (!isset($route['methods'])) { - $methods = $route['methods']; - } - - # Is $methods a String, not '*' and not '' ? - if ( - \is_string($route['methods']) && - \strcasecmp($route['methods'],'*')!=0 && - !empty(\trim($route['methods'])) - ) - { - # Support giving methods as comma separated string - $methods = \array_values(\explode(',',$route['methods'])); - # Trim spaces/specials from values - $methods = \array_map('trim',$methods); - } else - # Is it an array, but NOT emtpy ? - if (isset($route['methods']) && \is_array($route['methods']) && \count($route['methods'])>0) { - $methods = $route['methods']; - } - - if ( isset($route['path']) && isset($route['handler']) ) { - $this->addRoute($methods,\ltrim($route['path'],'/'),$route['handler']); - } - } - } - - /** - * Add a new route in the route collector - * - * @param array $methods The methods - * @param string $path [description] - * @param string $handler [description] - * - * @return self - */ - public function addRoute(array $methods, string $path, string $handler) - { - $fullPath = $this->getPrefix().(!empty($path) ? '/'.$path : ''); - if (empty($fullPath)) $fullPath = '/'; - - $this->routeCollector->addRoute( - $methods, - $fullPath, - $handler - ); - - return $this; - } - - - /** - * Match the $uri against the stored routes - * - * @param string $method The method - * @param string $uri HTTP Method name - * (GET,POST,PUT,DELETE,HEAD,OPTIONS) - * @param string $uri [description] - * - * @return array Array with matching info - */ - public function matchRoute( string $method, string $uri ) - { - # Make dispatcher - $dispatcher = new \FastRoute\Dispatcher\GroupCountBased($this->routeCollector->getData()); - - # Dispatch the requested METHOD and URI and see if a route matches - $routeInfo = $dispatcher->dispatch($method, $uri); - - # Examine $RouteInfo response - switch ($routeInfo[0]) - { - # We found a route match - case \FastRoute\Dispatcher::FOUND: - # URLDecode each argument - foreach ($routeInfo[2] as $idx=>$r) - { - $routeInfo[2][$idx] = \urldecode($r); - } - - # Return the Handler + args - return [ - 'method'=>$method, - 'path'=>$uri, - 'handler'=>$routeInfo[1], - 'args'=>$routeInfo[2] - ]; - - # Nothing found - case \FastRoute\Dispatcher::NOT_FOUND: - return []; - - # This should never happen to us ... - case \FastRoute\Dispatcher::METHOD_NOT_ALLOWED: - return []; - - # Default we return empty - default: - return []; - } - } - - /** - * Get the RouteGroup Name - * - * @return string - */ - public function getName(): string - { - return $this->name; - } - - /** - * Get the RouteGroup Prefix - * - * @return string - */ - public function getPrefix(): string - { - return $this->prefix; - } - - /** - * Get the Before Middleware array - * - * @return array - */ - public function getBeforeMiddleware(): array - { - return $this->beforeMiddleware; - } - - /** - * Get the After Middleware array - * - * @return array - */ - public function getAfterMiddleware(): array - { - return $this->afterMiddleware; - } - - /** - * Get the RouteGroup Routes array - * - * @return array - */ - public function getRoutes(): array - { - return $this->routes; - } - + /** @var string Name of group */ + protected string $name; + + /** @var string Path prefix */ + protected string $prefix; + + /** @var array Array of middleware */ + protected array $beforeMiddleware = []; + + /** @var array Array of middleware */ + protected array $afterMiddleware = []; + + /** @var array Array of routes */ + protected array $routes; + + /** @var \FastRoute\RouteCollector|null Collector */ + protected ?\FastRoute\RouteCollector $routeCollector = null; + + /** + * Constructor + * + * @param array $definition [description] + */ + public function __construct(array $definition) + { + # Route Group properties + $this->name = $definition['name'] ?? ''; + $this->prefix = $definition['prefix'] ?? ''; + $this->beforeMiddleware = $definition['before'] ?? []; + $this->routes = $definition['routes'] ?? []; + $this->afterMiddleware = $definition['after'] ?? []; + + # Route Parser, dataGenrator & RouteCollector + $routeParser = new \FastRoute\RouteParser\Std(); + $dataGenerator = new \FastRoute\DataGenerator\GroupCountBased(); + $this->routeCollector = new \FastRoute\RouteCollector($routeParser,$dataGenerator); + + # Add the Routes + foreach ($this->routes as $route) + { + # Method extraction + + # Default to ALL + $methods = ['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS']; + + if (isset($route['methods'])) { + $methods = $route['methods']; + } + + # Is $methods a String, not '*' and not '' ? + if ( + \is_string($route['methods']) && + \strcasecmp($route['methods'],'*')!=0 && + !empty(\trim($route['methods'])) + ) + { + # Support giving methods as comma separated string + $methods = \array_values(\explode(',',$route['methods'])); + # Trim spaces/specials from values + $methods = \array_map('trim',$methods); + } else + # Is it an array, but NOT emtpy ? + if (isset($route['methods']) && \is_array($route['methods']) && \count($route['methods'])>0) { + $methods = $route['methods']; + } + + if ( isset($route['path']) && isset($route['handler']) ) { + $this->addRoute($methods,\ltrim($route['path'],'/'),$route['handler']); + } + } + } + + /** + * Add a new route to the collector + * + * @param array $methods HTTP methods + * @param string $path Route path + * @param string $handler Handler class@method + * + * @return self + */ + public function addRoute(array $methods, string $path, string $handler) + { + $fullPath = $this->getPrefix().(!empty($path) ? '/'.$path : ''); + if (empty($fullPath)) $fullPath = '/'; + + $this->routeCollector->addRoute( + $methods, + $fullPath, + $handler + ); + + return $this; + } + + /** + * Match an incoming request against the group's routes + * + * @param string $method HTTP method + * @param string $uri Request URI path + * + * @return array + */ + public function matchRoute( string $method, string $uri ) + { + # Make dispatcher + $dispatcher = new \FastRoute\Dispatcher\GroupCountBased($this->routeCollector->getData()); + + # Dispatch the requested METHOD and URI and see if a route matches + $routeInfo = $dispatcher->dispatch($method, $uri); + + # Examine $RouteInfo response + switch ($routeInfo[0]) + { + # We found a route match + case \FastRoute\Dispatcher::FOUND: + # URLDecode each argument + foreach ($routeInfo[2] as $idx=>$r) + { + $routeInfo[2][$idx] = \urldecode($r); + } + + # Return the Handler + args + return [ + 'method'=>$method, + 'path'=>$uri, + 'handler'=>$routeInfo[1], + 'args'=>$routeInfo[2] + ]; + + # Nothing found + case \FastRoute\Dispatcher::NOT_FOUND: + return []; + + # This should never happen to us ... + case \FastRoute\Dispatcher::METHOD_NOT_ALLOWED: + return []; + + # Default we return empty + default: + return []; + } + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @return string + */ + public function getPrefix(): string + { + return $this->prefix; + } + + /** + * @return array + */ + public function getBeforeMiddleware(): array + { + return $this->beforeMiddleware; + } + + /** + * @return array + */ + public function getAfterMiddleware(): array + { + return $this->afterMiddleware; + } + + /** + * @return array + */ + public function getRoutes(): array + { + return $this->routes; + } } diff --git a/src/Core/RouteGroupInterface.php b/src/Core/RouteGroupInterface.php index ed31592..5b2ecf4 100644 --- a/src/Core/RouteGroupInterface.php +++ b/src/Core/RouteGroupInterface.php @@ -1,9 +1,15 @@ delegate( new ReflectionContainer ); } - \logger()->debug('Created PSR-11 Container (The Leauge Container)'); + \logger()->debug('Created PSR-11 Container (The League Container)'); return $container; } diff --git a/src/Factories/Http/RequestFactory.php b/src/Factories/Http/RequestFactory.php index 8861422..aaea223 100644 --- a/src/Factories/Http/RequestFactory.php +++ b/src/Factories/Http/RequestFactory.php @@ -28,18 +28,13 @@ class RequestFactory extends AbstractFactory implements RequestFactoryInterface { /** - * Create a new request. - * - * @param string $method - * @param UriInterface|string $uri - * - * @return RequestInterface + * @inheritDoc */ public function createRequest(string $method, $uri): RequestInterface { $request = new Request($method, $uri); - \logger()->debug('Created PSR-7 Request("'.$method.'","'.$uri.'"") (Guzzle)'); + \logger()?->debug('Created PSR-7 Request("'.$method.'","'.$uri.'"") (Guzzle)'); return $request; } diff --git a/src/Factories/Http/ResponseFactory.php b/src/Factories/Http/ResponseFactory.php index 754a67f..3a99961 100644 --- a/src/Factories/Http/ResponseFactory.php +++ b/src/Factories/Http/ResponseFactory.php @@ -28,17 +28,13 @@ class ResponseFactory extends AbstractFactory implements ResponseFactoryInterface { /** - * Create a new response. - * - * @param integer $code HTTP status code - * - * @return ResponseInterface + * @inheritDoc */ public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface { $response = new Response($code); - \logger()->debug('Created PSR-7 Response (Guzzle)'); + \logger()?->debug('Created PSR-7 Response (Guzzle)'); return $response; } diff --git a/src/Factories/Http/ServerRequestFactory.php b/src/Factories/Http/ServerRequestFactory.php index 3feee7f..cf455ec 100644 --- a/src/Factories/Http/ServerRequestFactory.php +++ b/src/Factories/Http/ServerRequestFactory.php @@ -33,28 +33,19 @@ class ServerRequestFactory extends AbstractFactory implements ServerRequestFactoryInterface { /** - * Create a new server request - * - * @param string $method - * @param UriInterface|string $uri - * @param array $serverParams The server parameters - * - * @return ServerRequestInterface + * @inheritDoc */ public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface { - # Copied from Guzzles ::fromGlobals(), but we need to support the $server array as - # paramter, so we use that instead of the $_SERVER array guzzle uses by default - - $method = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET'; + # Use provided arguments, do not override with globals $headers = \function_exists('getallheaders') ? \getallheaders() : []; - $uri = (string) $uri; + $uriString = (string) $uri; $body = new LazyOpenStream('php://input', 'r+'); - $protocol = isset($_SERVER['SERVER_PROTOCOL']) ? \str_replace('HTTP/', '', $_SERVER['SERVER_PROTOCOL']) : '1.1'; + $protocol = isset($serverParams['SERVER_PROTOCOL']) ? \str_replace('HTTP/', '', $serverParams['SERVER_PROTOCOL']) : '1.1'; - $serverRequest = new ServerRequest($method, $uri, $headers, $body, $protocol, $_SERVER); + $serverRequest = new ServerRequest($method, $uriString, $headers, $body, $protocol, $serverParams); - \logger()->debug('Created PSR-7 ServerRequest("'.$method.'","'.$uri.'") (Guzzle)'); + \logger()?->debug('Created PSR-7 ServerRequest("'.$method.'","'.$uriString.'") (Guzzle)'); return $serverRequest ->withCookieParams($_COOKIE) @@ -66,8 +57,7 @@ public function createServerRequest(string $method, $uri, array $serverParams = /** * Create a new server request from server variables array * - * @param array $server Typically $_SERVER or similar - * array + * @param array|null $server Typically $_SERVER or similar array * * @return ServerRequestInterface * @@ -88,7 +78,7 @@ public function createServerRequestFromArray(?array $server) $serverRequest = new ServerRequest($method, $uri, $headers, $body, $protocol, $server); - \logger()->debug('Created PSR-7 ServerRequest("'.$method.'","'.$uri.'") from array (Guzzle)'); + \logger()?->debug('Created PSR-7 ServerRequest("'.$method.'","'.$uri.'") from array (Guzzle)'); return $serverRequest ->withCookieParams($_COOKIE) diff --git a/src/Helpers.php b/src/Helpers.php index 35fa0a2..4e7ace2 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -1,11 +1,17 @@ = 2 && $val[0] === '"' && $val[$len - 1] === '"') { return \trim($val, '"'); } @@ -249,7 +256,12 @@ function queryParam(string $paramName, mixed $default = null): mixed { global $app; - return $app->getRequest()->getQueryParams()[$paramName] ?? $default; + $request = $app->getRequest(); + $uri = $request->getUri(); + $query = $uri->getQuery(); + parse_str($query, $queryParams); + + return $queryParams[$paramName] ?? $default; } } @@ -263,7 +275,12 @@ function queryParams(): array { global $app; - return $app->getRequest()->getQueryParams() ?? []; + $request = $app->getRequest(); + $uri = $request->getUri(); + $query = $uri->getQuery(); + parse_str($query, $queryParams); + + return $queryParams ?? []; } } @@ -280,7 +297,14 @@ function postParam(string $paramName, mixed $default = null): mixed { global $app; - return $app->getRequest()->getParsedBody()[$paramName] ?? $default; + $request = $app->getRequest(); + $body = $request->getBody(); + $contents = $body->getContents(); + + // Parse POST data from request body + parse_str($contents, $postParams); + + return $postParams[$paramName] ?? $default; } } @@ -292,7 +316,16 @@ function postParam(string $paramName, mixed $default = null): mixed */ function postParams(): array { - return $_POST; + global $app; + + $request = $app->getRequest(); + $body = $request->getBody(); + $contents = $body->getContents(); + + // Parse POST data from request body + parse_str($contents, $postParams); + + return $postParams ?? []; } } @@ -309,7 +342,18 @@ function cookieParam(string $paramName, mixed $default = null): mixed { global $app; - return $app->getRequest()->getCookieParams()[$paramName] ?? $default; + $request = $app->getRequest(); + $cookies = $request->getHeader('Cookie'); + + if (empty($cookies)) { + return $default; + } + + // Parse cookie header + $cookieString = $cookies[0]; + parse_str(str_replace('; ', '&', $cookieString), $cookieParams); + + return $cookieParams[$paramName] ?? $default; } } @@ -323,7 +367,18 @@ function cookieParams(): array { global $app; - return $app->getRequest()->getCookieParams() ?? []; + $request = $app->getRequest(); + $cookies = $request->getHeader('Cookie'); + + if (empty($cookies)) { + return []; + } + + // Parse cookie header + $cookieString = $cookies[0]; + parse_str(str_replace('; ', '&', $cookieString), $cookieParams); + + return $cookieParams ?? []; } } @@ -374,9 +429,9 @@ function cookie(string $name, * @param int $status Status code, defaults to 302 * @param array $headers Additional headers * - * @return object + * @return Response */ - function redirect(string $uri, int $status = 302, array $headers = []): object + function redirect(string $uri, int $status = 302, array $headers = []): Response { global $app; @@ -387,7 +442,11 @@ function redirect(string $uri, int $status = 302, array $headers = []): object # Set all the headers the user sent foreach($headers as $header => $values) { - $response = $response->withHeader($header,$values); + if (is_array($values)) { + $response = $response->withHeader($header, $values); + } else { + $response = $response->withHeader($header, (string)$values); + } } # Set it @@ -422,7 +481,11 @@ function response(string $body = '', int $code = 200, array $headers = []): Resp # Set all the headers the user sent foreach($headers as $header => $values) { - $response = $response->withHeader($header,$values); + if (is_array($values)) { + $response = $response->withHeader($header, $values); + } else { + $response = $response->withHeader($header, (string)$values); + } } if ($body !== '') { @@ -454,7 +517,7 @@ function responseJson(array $data = [], int $code = 200, int $options = 0, array try { $body = \json_encode($data, JSON_THROW_ON_ERROR | $options); } catch (\JsonException $e) { - \logger()->warning('Invalid payload for responseJson', [ + \logger()?->warning('Invalid payload for responseJson', [ 'error' => $e->getMessage(), 'rid' => container('rid') ]); @@ -595,13 +658,13 @@ function getConfigPath(): string if(!\function_exists('mime_content_type')) { /** - * { function_description } + * Get MIME type for a file (alias for mime_content_type_ex) * * @param string $filename The filename * - * @return array|string The mime type(s) of the file + * @return string The mime type of the file */ - function mime_content_type(string $filename): array|string + function mime_content_type(string $filename): string { return \mime_content_type_ex($filename); } @@ -609,13 +672,13 @@ function mime_content_type(string $filename): array|string if(!\function_exists('mime_content_type_ex')) { /** - * { function_description } + * Get MIME type for a file based on extension or file content * * @param string $filename The filename * - * @return array|string The mime type(s) of the file + * @return string The mime type of the file */ - function mime_content_type_ex(string $filename): array|string + function mime_content_type_ex(string $filename): string { $mime_types = [ 'txt' => 'text/plain', @@ -690,7 +753,10 @@ function mime_content_type_ex(string $filename): array|string $mimetype = \finfo_file($finfo, $filename); \finfo_close($finfo); - return $mimetype; + // Ensure we return a valid string, not false + if ($mimetype !== false && \is_string($mimetype)) { + return $mimetype; + } } return 'application/octet-stream'; diff --git a/src/Helpers/ArrayToXml.php b/src/Helpers/ArrayToXml.php index d4d2c08..9386def 100644 --- a/src/Helpers/ArrayToXml.php +++ b/src/Helpers/ArrayToXml.php @@ -1,18 +1,27 @@ assertEquals($basePath, $this->app->getBasePath()); - + // Test app path $this->assertEquals($basePath . DIRECTORY_SEPARATOR . 'app', $this->app->getAppPath()); - + // Test config path $this->assertEquals($basePath . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'Config', $this->app->getConfigPath()); - + // Test storage path $this->assertEquals($basePath . DIRECTORY_SEPARATOR . 'storage', $this->app->getStoragePath()); - + // Test shared storage path (should default to storage path if not configured) $this->assertIsString($this->app->getSharedStoragePath()); } @@ -68,7 +68,7 @@ public function testApplicationInfo(): void $this->assertIsString($this->app->getAppName()); $this->assertIsString($this->app->getAppCode()); $this->assertIsString($this->app->getAppVersion()); - + // They might be empty if not configured, which is ok // Just verify they don't throw exceptions $this->assertTrue(true); @@ -83,11 +83,11 @@ public function testEnvironment(): void $currentEnv = $this->app->getEnvironment(); $this->assertIsString($currentEnv); $this->assertNotEmpty($currentEnv); - + // Test setting environment $this->app->setEnvironment('testing'); $this->assertEquals('testing', $this->app->getEnvironment()); - + // Restore original $this->app->setEnvironment($currentEnv); } @@ -99,7 +99,7 @@ public function testConfig(): void { $config = $this->app->getConfig(); $this->assertInstanceOf(Config::class, $config); - + // Test that config has the basic structure $this->assertIsObject($config); } @@ -113,7 +113,7 @@ public function testApplicationLogger(): void // Test global logger function $this->assertSame($logger, \logger()); - + // Test logging \logger()->notice('This is a test log line', ['test' => true]); $this->assertTrue(true); // If we get here, logging worked @@ -126,14 +126,14 @@ public function testContainer(): void { $container = $this->app->getContainer(); $this->assertIsObject($container); - + // Test setting and getting container values $testKey = 'test_' . uniqid(); $testValue = 'test_value_' . uniqid(); - + $this->app->container($testKey, $testValue); $this->assertEquals($testValue, $this->app->container($testKey)); - + // Test non-existent key $this->assertNull($this->app->container('non_existent_key_' . uniqid())); } @@ -168,14 +168,14 @@ public function testRouteGroups(): void { $routeGroups = $this->app->getRouteGroups(); $this->assertIsArray($routeGroups); - + // Test getting specific route group if (!empty($routeGroups)) { $firstGroupName = array_key_first($routeGroups); $routeGroup = $this->app->getRouteGroup($firstGroupName); $this->assertInstanceOf(RouteGroup::class, $routeGroup); } - + // Test non-existent route group $this->assertNull($this->app->getRouteGroup('non_existent_group_' . uniqid('', true))); } @@ -190,13 +190,13 @@ public function testRequestResponse(): void if ($request !== null) { $this->assertInstanceOf(\GuzzleHttp\Psr7\Request::class, $request); } - + // Response might be null before run() $response = $this->app->getResponse(); if ($response !== null) { $this->assertInstanceOf(Response::class, $response); } - + // Test setting response $newResponse = new Response(200, [], 'Test response'); $this->app->setResponse($newResponse); @@ -219,17 +219,17 @@ public function testGlobalVariables(): void $globalVars = $this->app->getGlobalVars(); $this->assertIsArray($globalVars); } - + // Test setting individual global var $key = 'test_var_' . uniqid('', true); $value = 'test_value_' . uniqid('', true); - + $this->app->setGlobalVar($key, $value); $this->assertEquals($value, $this->app->getGlobalVar($key)); - + // Test non-existent global var $this->assertNull($this->app->getGlobalVar('non_existent_' . uniqid('', true))); - + // Test setting all global vars $newGlobalVars = ['key1' => 'value1', 'key2' => 'value2']; $this->app->setGlobalVars($newGlobalVars); @@ -244,7 +244,7 @@ public function testPropertyAccess(): void // Test getting existing properties $environment = $this->app->getProperty('environment'); $this->assertIsString($environment); - + // Test non-existent property $nonExistent = $this->app->getProperty('non_existent_property_' . uniqid('', true)); $this->assertNull($nonExistent); @@ -277,15 +277,15 @@ public function testFileResponse(): void // Create a temporary file for testing $tempFile = sys_get_temp_dir() . '/test_file_' . uniqid('', true) . '.txt'; file_put_contents($tempFile, 'Test content'); - + // Test setting file response $this->app->setFileResponse($tempFile); - + // Clean up if (file_exists($tempFile)) { unlink($tempFile); } - + $this->assertTrue(true); // If we get here, setFileResponse worked } @@ -309,13 +309,13 @@ public function testRunErrorController(): void public function testExceptionHandler(): void { $exception = new \Exception('Test exception'); - + // We can't easily test the actual exception handler behavior, // but we can verify it doesn't throw an error ob_start(); $result = $this->app->exceptionHandler($exception); ob_end_clean(); - + // The handler should return something (even if null) $this->assertTrue(true); // If we get here, handler didn't crash } @@ -330,10 +330,10 @@ public function testErrorHandler(): void E_NOTICE, 'Test notice', __FILE__, - __LINE__, + (string)__LINE__, [] ); - + $this->assertIsBool($result); } @@ -353,7 +353,7 @@ public function testRun(): void 'SCRIPT_NAME' => '/index.php', 'PHP_SELF' => '/index.php', ]; - + $this->assertTrue($this->app->run($serverRequest)); } @@ -365,7 +365,7 @@ public function testMiddleware(): void // These are protected properties, so we test them indirectly // by verifying the app can handle requests (middleware chain works) $this->assertInstanceOf(\Spin\Application::class, $this->app); - + // If middleware was broken, run() would fail $this->assertTrue(true); } @@ -376,12 +376,7 @@ public function testMiddleware(): void public function testConfigFiles(): void { $config = $this->app->getConfig(); - - // Check if config has expected structure - if (property_exists($config, 'application')) { - $this->assertIsObject($config->application); - } - + // Config should at least be an object $this->assertIsObject($config); } diff --git a/tests/Cache/Adapters/RedisTest.php b/tests/Cache/Adapters/RedisTest.php index 769d3b9..68b0e58 100644 --- a/tests/Cache/Adapters/RedisTest.php +++ b/tests/Cache/Adapters/RedisTest.php @@ -19,7 +19,8 @@ public function setUp(): void 'port' => 6379 ] ]); - + // Force connectivity check; skip if not reachable + $this->cacheObj->statistics(); // Clear any existing data before tests $this->cacheObj->clear(); } catch (\Exception $e) { diff --git a/tests/Core/ConfigTest.php b/tests/Core/ConfigTest.php new file mode 100644 index 0000000..2bf5bb4 --- /dev/null +++ b/tests/Core/ConfigTest.php @@ -0,0 +1,86 @@ + +replaceEnvMacros($input, $envVars); + } +} + +class ConfigTest extends TestCase +{ + public function testReplaceEnvMacrosReplacesEnvVars() { + $config = new ConfigTestReplaceEnvMacros('', ''); + $input = [ + 'db' => [ + 'host' => '${env:DB_HOST}', + 'user' => '${env:DB_USER}', + 'pass' => 'static', + ], + 'plain' => 'no-macro', + ]; + $envVars = [ + 'DB_HOST' => 'localhost', + 'DB_USER' => 'root', + ]; + + $expected = [ + 'db' => [ + 'host' => 'localhost', + 'user' => 'root', + 'pass' => 'static', + ], + 'plain' => 'no-macro', + ]; + + $result = $config->publicReplaceEnvMacros($input, $envVars); + + $this->assertEquals($expected, $result); + } + + public function testReplaceEnvMacrosMissingEnvVar() { + $config = new ConfigTestReplaceEnvMacros('', ''); + $input = [ + 'api' => '${env:API_KEY}', + ]; + $envVars = []; + $expected = [ + 'api' => '', + ]; + $result = $config->publicReplaceEnvMacros($input, $envVars); + $this->assertEquals($expected, $result); + } + + public function testReplaceEnvMacrosNestedArrays() { + $config = new ConfigTestReplaceEnvMacros('', ''); + $input = [ + 'outer' => [ + 'inner' => '${env:VAR}', + ], + ]; + $envVars = [ 'VAR' => 'value' ]; + $expected = [ + 'outer' => [ + 'inner' => 'value', + ], + ]; + $result = $config->publicReplaceEnvMacros($input, $envVars); + $this->assertEquals($expected, $result); + } + + public function testReplaceEnvMacrosMultipleMacrosInString() { + $config = new ConfigTestReplaceEnvMacros('', ''); + $input = [ + 'multi' => 'User: ${env:USER}, Host: ${env:HOST}', + ]; + $envVars = [ 'USER' => 'admin', 'HOST' => 'server' ]; + $expected = [ + 'multi' => 'User: admin, Host: server', + ]; + $result = $config->publicReplaceEnvMacros($input, $envVars); + $this->assertEquals($expected, $result); + } +} diff --git a/tests/Helpers/GlobalHelpersTest.php b/tests/Helpers/GlobalHelpersTest.php new file mode 100644 index 0000000..f443f6d --- /dev/null +++ b/tests/Helpers/GlobalHelpersTest.php @@ -0,0 +1,413 @@ +app = $app; + } + + /** + * Test env() function + */ + public function testEnv(): void + { + // Test with existing environment variable + putenv('TEST_VAR=test_value'); + $this->assertEquals('test_value', env('TEST_VAR')); + + // Test with default value for non-existent variable + $this->assertEquals('default_value', env('NON_EXISTENT_VAR', 'default_value')); + + // Test boolean values + putenv('BOOL_TRUE=true'); + putenv('BOOL_FALSE=false'); + $this->assertTrue(env('BOOL_TRUE')); + $this->assertFalse(env('BOOL_FALSE')); + + // Test null and empty values + putenv('NULL_VAL=null'); + putenv('EMPTY_VAL=empty'); + $this->assertNull(env('NULL_VAL')); + $this->assertEquals('', env('EMPTY_VAL')); + + // Test quoted values + putenv('QUOTED_VAL="quoted_string"'); + $this->assertEquals('quoted_string', env('QUOTED_VAL')); + + // Clean up + putenv('TEST_VAR'); + putenv('BOOL_TRUE'); + putenv('BOOL_FALSE'); + putenv('NULL_VAL'); + putenv('EMPTY_VAL'); + putenv('QUOTED_VAL'); + } + + /** + * Test app() function + */ + public function testApp(): void + { + // Test getting the app object + $this->assertSame($this->app, app()); + + // Test getting a property (this should work with the real app) + $this->assertNotNull(app('config')); + } + + /** + * Test config() function + */ + public function testConfig(): void + { + // Test getting config object + $config = config(); + $this->assertNotNull($config); + + // Test getting a config value (if any exists) + $appName = config('application.name'); + // This might be null if no config is loaded, but shouldn't throw an error + $this->assertTrue(true); // Just ensure no exception is thrown + } + + /** + * Test container() function + */ + public function testContainer(): void + { + // Test getting container + $container = container(); + $this->assertNotNull($container); + } + + /** + * Test logger() function + */ + public function testLogger(): void + { + $logger = logger(); + $this->assertNotNull($logger); + } + + /** + * Test getRequest() function + */ + public function testGetRequest(): void + { + $request = getRequest(); + $this->assertInstanceOf(Request::class, $request); + } + + /** + * Test getResponse() function + */ + public function testGetResponse(): void + { + $response = getResponse(); + $this->assertInstanceOf(Response::class, $response); + } + + /** + * Test queryParam() function + */ + public function testQueryParam(): void + { + // Test getting non-existent parameter with default + $this->assertEquals('default_value', queryParam('non_existent', 'default_value')); + + // Test getting non-existent parameter without default + $this->assertNull(queryParam('non_existent')); + } + + /** + * Test queryParams() function + */ + public function testQueryParams(): void + { + // Test getting all query parameters + $params = queryParams(); + $this->assertIsArray($params); + // This might be empty if no query string, but should be an array + } + + /** + * Test postParam() function + */ + public function testPostParam(): void + { + // Test getting non-existent parameter with default + $this->assertEquals('default_value', postParam('non_existent', 'default_value')); + + // Test getting non-existent parameter without default + $this->assertNull(postParam('non_existent')); + } + + /** + * Test postParams() function + */ + public function testPostParams(): void + { + // Test getting all POST parameters + $params = postParams(); + $this->assertIsArray($params); + // This might be empty if no POST data, but should be an array + } + + /** + * Test cookieParam() function + */ + public function testCookieParam(): void + { + // Test getting non-existent cookie with default + $this->assertEquals('default_value', cookieParam('non_existent', 'default_value')); + + // Test getting non-existent cookie without default + $this->assertNull(cookieParam('non_existent')); + } + + /** + * Test cookieParams() function + */ + public function testCookieParams(): void + { + // Test getting all cookies + $cookies = cookieParams(); + $this->assertIsArray($cookies); + // This might be empty if no cookies, but should be an array + } + + /** + * Test redirect() function + */ + public function testRedirect(): void + { + $result = redirect('http://example.com', 301, ['X-Custom-Header' => 'value']); + $this->assertInstanceOf(Response::class, $result); + $this->assertEquals(301, $result->getStatusCode()); + $this->assertEquals('http://example.com', $result->getHeaderLine('Location')); + $this->assertEquals('value', $result->getHeaderLine('X-Custom-Header')); + } + + /** + * Test response() function + */ + public function testResponse(): void + { + $result = response('Test body', 201, ['X-Custom-Header' => 'value']); + $this->assertInstanceOf(Response::class, $result); + $this->assertEquals(201, $result->getStatusCode()); + $this->assertEquals('Test body', (string) $result->getBody()); + $this->assertEquals('value', $result->getHeaderLine('X-Custom-Header')); + } + + /** + * Test responseJson() function + */ + public function testResponseJson(): void + { + $data = ['message' => 'Success', 'status' => 'ok']; + $result = responseJson($data, 200, JSON_PRETTY_PRINT, ['X-Custom-Header' => 'value']); + + $this->assertInstanceOf(Response::class, $result); + $this->assertEquals(200, $result->getStatusCode()); + $this->assertEquals('application/json', $result->getHeaderLine('Content-Type')); + $this->assertEquals('value', $result->getHeaderLine('X-Custom-Header')); + + // Verify JSON content + $body = (string) $result->getBody(); + $decoded = json_decode($body, true); + $this->assertEquals($data, $decoded); + } + + /** + * Test responseJson() with invalid data + */ + public function testResponseJsonInvalidData(): void + { + // Create data that can't be JSON encoded (resource) + $resource = fopen('php://temp', 'r'); + $result = responseJson(['resource' => $resource], 200); + + $this->assertInstanceOf(Response::class, $result); + $this->assertEquals('', (string) $result->getBody()); + + fclose($resource); + } + + /** + * Test responseXml() function + */ + public function testResponseXml(): void + { + $data = ['item' => ['name' => 'Test', 'value' => '123']]; + $result = responseXml($data, 'root', 200, ['X-Custom-Header' => 'value']); + + $this->assertInstanceOf(Response::class, $result); + $this->assertEquals(200, $result->getStatusCode()); + $this->assertEquals('application/xml', $result->getHeaderLine('Content-Type')); + $this->assertEquals('value', $result->getHeaderLine('X-Custom-Header')); + + // Verify XML content + $body = (string) $result->getBody(); + $this->assertStringContainsString('', $body); + $this->assertStringContainsString('', $body); + $this->assertStringContainsString('Test', $body); + } + + /** + * Test responseHtml() function + */ + public function testResponseHtml(): void + { + $result = responseHtml('

Hello World

', 200, ['X-Custom-Header' => 'value']); + + $this->assertInstanceOf(Response::class, $result); + $this->assertEquals(200, $result->getStatusCode()); + $this->assertEquals('text/html', $result->getHeaderLine('Content-Type')); + $this->assertEquals('value', $result->getHeaderLine('X-Custom-Header')); + $this->assertEquals('

Hello World

', (string) $result->getBody()); + } + + /** + * Test responseFile() function + */ + public function testResponseFile(): void + { + // Create a temporary file + $tempFile = tempnam(sys_get_temp_dir(), 'test_'); + file_put_contents($tempFile, 'Test file content'); + + $result = responseFile($tempFile, 200, ['X-Custom-Header' => 'value'], true); + + $this->assertInstanceOf(Response::class, $result); + $this->assertEquals(200, $result->getStatusCode()); + $this->assertEquals('value', $result->getHeaderLine('X-Custom-Header')); + + // Clean up + unlink($tempFile); + } + + /** + * Test getClientIp() function + */ + public function testGetClientIp(): void + { + // Test with HTTP_CLIENT_IP + $_SERVER['HTTP_CLIENT_IP'] = '192.168.1.100'; + $this->assertEquals('192.168.1.100', getClientIp()); + + // Test with HTTP_X_FORWARDED_FOR + unset($_SERVER['HTTP_CLIENT_IP']); + $_SERVER['HTTP_X_FORWARDED_FOR'] = '10.0.0.1'; + $this->assertEquals('10.0.0.1', getClientIp()); + + // Test with REMOTE_ADDR + unset($_SERVER['HTTP_X_FORWARDED_FOR']); + $_SERVER['REMOTE_ADDR'] = '172.16.0.1'; + $this->assertEquals('172.16.0.1', getClientIp()); + + // Test with invalid IP + $_SERVER['REMOTE_ADDR'] = 'invalid_ip'; + $this->assertEquals('0.0.0.0', getClientIp()); + + // Test with no IP + unset($_SERVER['REMOTE_ADDR']); + $this->assertEquals('0.0.0.0', getClientIp()); + } + + /** + * Test generateRefId() function + */ + public function testGenerateRefId(): void + { + // Test without prefix + $refId1 = generateRefId(); + $this->assertIsString($refId1); + $this->assertGreaterThan(0, strlen($refId1)); + + // Test with prefix + $refId2 = generateRefId('TEST_'); + $this->assertStringStartsWith('TEST_', $refId2); + + // Test uniqueness (with a longer delay to ensure different timestamps) + usleep(10000); // 10ms delay + $refId3 = generateRefId(); + $this->assertNotEquals($refId1, $refId3); + } + + /** + * Test getConfigPath() function + */ + public function testGetConfigPath(): void + { + $configPath = getConfigPath(); + $this->assertIsString($configPath); + $this->assertStringEndsWith('Config', $configPath); + } + + /** + * Test mime_content_type() function + */ + public function testMimeContentType(): void + { + // Since mime_content_type is a built-in PHP function, test our custom mime_content_type_ex instead + // Test with known extension + $this->assertEquals('text/html', mime_content_type_ex('test.html')); + $this->assertEquals('application/json', mime_content_type_ex('data.json')); + $this->assertEquals('image/png', mime_content_type_ex('image.png')); + + // Test with unknown extension + $this->assertEquals('application/octet-stream', mime_content_type_ex('unknown.xyz')); + } + + /** + * Test mime_content_type_ex() function + */ + public function testMimeContentTypeEx(): void + { + // Test with known extensions + $this->assertEquals('text/html', mime_content_type_ex('test.html')); + $this->assertEquals('application/json', mime_content_type_ex('data.json')); + $this->assertEquals('image/png', mime_content_type_ex('image.png')); + + // Test with unknown extension + $this->assertEquals('application/octet-stream', mime_content_type_ex('unknown.xyz')); + + // Test with mixed case + $this->assertEquals('text/html', mime_content_type_ex('test.HTML')); + $this->assertEquals('application/json', mime_content_type_ex('data.JSON')); + } + + /** + * Clean up after tests + */ + public function tearDown(): void + { + // Clean up server variables + unset($_SERVER['HTTP_CLIENT_IP']); + unset($_SERVER['HTTP_X_FORWARDED_FOR']); + unset($_SERVER['REMOTE_ADDR']); + } +} diff --git a/tests/app/Config/config-dev.json b/tests/app/Config/config-dev.json new file mode 100644 index 0000000..0196195 --- /dev/null +++ b/tests/app/Config/config-dev.json @@ -0,0 +1,33 @@ +{ + "application": { + "global": { + "maintenance": false, + "timezone": "UTC" + }, + "secret": "dev-secret-key" + }, + "session": { + "cookie": "SID", + "timeout": 3600, + "driver": "apcu" + }, + "logger": { + "level": "debug", + "driver": "php" + }, + "caches": { + "default": { + "adapter": "apcu", + "config": {} + } + }, + "factories": { + "http": { + "stream": { + "class": "\\Spin\\Factories\\Http\\StreamFactory" + } + } + }, + "hooks": [], + "connections": {} +} diff --git a/tests/app/Config/config-unittest.json b/tests/app/Config/config-unittest.json new file mode 100644 index 0000000..3ef608e --- /dev/null +++ b/tests/app/Config/config-unittest.json @@ -0,0 +1,33 @@ +{ + "application": { + "global": { + "maintenance": false, + "timezone": "UTC" + }, + "secret": "test-secret-key" + }, + "session": { + "cookie": "SID", + "timeout": 3600, + "driver": "apcu" + }, + "logger": { + "level": "debug", + "driver": "php" + }, + "caches": { + "default": { + "adapter": "apcu", + "config": {} + } + }, + "factories": { + "http": { + "stream": { + "class": "\\Spin\\Factories\\Http\\StreamFactory" + } + } + }, + "hooks": [], + "connections": {} +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 4c2edad..332b987 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -15,5 +15,8 @@ require __DIR__.'/../vendor/autoload.php'; +# Set environment for tests +putenv('ENVIRONMENT=unittest'); + # Create application $app = new \Spin\Application(\realpath(__DIR__));