diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b541bea..4b2b7bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,15 +3,15 @@ name: Create Release on: push: branches: - - master + - main pull_request: types: [closed] branches: - - master + - main jobs: create-release: - # Only run when PR is merged or direct push to master + # Only run when PR is merged or direct push to main if: github.event_name == 'push' || (github.event.pull_request.merged == true) runs-on: ubuntu-latest @@ -32,12 +32,12 @@ jobs: - name: Check if tag exists id: check_tag run: | - if git rev-parse "v${{ steps.get_version.outputs.version }}" >/dev/null 2>&1; then + if git rev-parse "${{ steps.get_version.outputs.version }}" >/dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT - echo "Tag v${{ steps.get_version.outputs.version }} already exists" + echo "Tag ${{ steps.get_version.outputs.version }} already exists" else echo "exists=false" >> $GITHUB_OUTPUT - echo "Tag v${{ steps.get_version.outputs.version }} does not exist" + echo "Tag ${{ steps.get_version.outputs.version }} does not exist" fi - name: Create tag @@ -45,8 +45,8 @@ jobs: run: | git config user.name github-actions[bot] git config user.email github-actions[bot]@users.noreply.github.com - git tag -a "v${{ steps.get_version.outputs.version }}" -m "Release version ${{ steps.get_version.outputs.version }}" - git push origin "v${{ steps.get_version.outputs.version }}" + git tag -a "${{ steps.get_version.outputs.version }}" -m "Release version ${{ steps.get_version.outputs.version }}" + git push origin "${{ steps.get_version.outputs.version }}" - name: Extract changelog for version id: changelog @@ -88,8 +88,8 @@ jobs: if: steps.check_tag.outputs.exists == 'false' uses: softprops/action-gh-release@v2 with: - tag_name: v${{ steps.get_version.outputs.version }} - name: v${{ steps.get_version.outputs.version }} + tag_name: ${{ steps.get_version.outputs.version }} + name: ${{ steps.get_version.outputs.version }} body: | ${{ steps.changelog.outputs.changelog }} draft: false diff --git a/VERSION b/VERSION index 4fe2fe8..f9bcd8b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.40 +0.0.41 \ No newline at end of file diff --git a/composer.json b/composer.json index c257b1c..28e25d7 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "celarius/spin-framework", "description": "A super lightweight PHP UI/REST Framework", - "version": "0.0.40", + "version": "0.0.41", "keywords": [ "php8", "php-framework", diff --git a/doc/Contributor-Guide/Getting-Started.md b/doc/Contributor-Guide/Getting-Started.md index e342632..7aad0b3 100644 --- a/doc/Contributor-Guide/Getting-Started.md +++ b/doc/Contributor-Guide/Getting-Started.md @@ -124,7 +124,7 @@ Types: `feat`, `fix`, `docs`, `test`, `refactor`, `perf`, `chore` 7. **Request review** from maintainers 8. **Address feedback** and update PR 9. **Squash commits** before merge if requested -10. **Merge to develop**, then maintainers merge to master +10. **Merge to develop**, then maintainers merge to main ### Code Review Process diff --git a/doc/Contributor-Guide/README.md b/doc/Contributor-Guide/README.md index f090536..59298b2 100644 --- a/doc/Contributor-Guide/README.md +++ b/doc/Contributor-Guide/README.md @@ -190,7 +190,7 @@ See [Extension-Points.md](Extension-Points.md) for detailed examples. - Respond to review feedback - Push updates (don't force-push) - Wait for approval -- Maintainer merges to develop, then master +- Maintainer merges to develop, then main ## Resources diff --git a/doc/Contributor-Guide/Submitting-Changes.md b/doc/Contributor-Guide/Submitting-Changes.md index 22824c1..5f224c5 100644 --- a/doc/Contributor-Guide/Submitting-Changes.md +++ b/doc/Contributor-Guide/Submitting-Changes.md @@ -162,7 +162,7 @@ Once approved: 1. **Squash commits** if requested (multiple commits combined) 2. **Merge to develop** via GitHub 3. **Delete feature branch** -4. **Maintainer merges develop → master** periodically +4. **Maintainer merges develop → main** periodically ## Changelog Updates @@ -398,15 +398,15 @@ Releases follow semantic versioning: `MAJOR.MINOR.PATCH` 3. **Create release commit** ```bash - git checkout master - git pull origin master + git checkout main + git pull origin main git commit -am "release: version 0.0.37" ``` 4. **Tag release** ```bash - git tag -a v0.0.37 -m "Release version 0.0.37" - git push origin master --tags + git tag -a 0.0.37 -m "Release version 0.0.37" + git push origin main --tags ``` 5. **Create GitHub release** diff --git a/doc/Contributor-Guide/Testing-Guide.md b/doc/Contributor-Guide/Testing-Guide.md index de52e3c..7530417 100644 --- a/doc/Contributor-Guide/Testing-Guide.md +++ b/doc/Contributor-Guide/Testing-Guide.md @@ -568,8 +568,8 @@ public function testUserValidation(): void Tests run automatically via GitHub Actions: 1. **On every push** to branches -2. **On pull requests** to develop/master -3. **Required before merge** to master (checks coverage, failures) +2. **On pull requests** to develop/main +3. **Required before merge** to main (checks coverage, failures) Verify locally before pushing: diff --git a/doc/Getting-Started/Project-Structure.md b/doc/Getting-Started/Project-Structure.md index db983a4..a4aaffa 100644 --- a/doc/Getting-Started/Project-Structure.md +++ b/doc/Getting-Started/Project-Structure.md @@ -45,6 +45,7 @@ my-app/ ├── src/ │ └── app/ │ ├── Config/ +│ │ ├── version.json Application identity (code, name, version) │ │ ├── config.json Environment-independent config │ │ ├── config-dev.json Development config │ │ ├── config-prod.json Production config @@ -89,18 +90,46 @@ my-app/ Configuration is environment-based and uses JSON: +### `version.json` (required) + +Sets your application's identity. The framework loads this automatically at startup before any other config file: + +```json +{ + "application": { + "code": "my-app", + "name": "My Application", + "version": "1.0.0" + } +} +``` + +| Field | Purpose | +|-------|---------| +| `code` | Machine identifier — used as Monolog log channel name and shared-storage path suffix | +| `name` | Human-readable application label | +| `version` | Semver version string | + +Access at runtime: + +```php +app()->getAppCode(); // "my-app" +app()->getAppName(); // "My Application" +app()->getAppVersion(); // "1.0.0" +``` + ### `config.json` (shared) Global settings used across all environments: ```json { - "app": { - "name": "My App", - "timezone": "UTC", - "charset": "UTF-8" - }, - "cache": { - "default": "file" + "application": { + "global": { + "maintenance": false, + "message": "We are in maintenance mode, back shortly", + "timezone": "UTC" + }, + "secret": "${env:APPLICATION_SECRET}" } } ``` @@ -110,24 +139,48 @@ Development overrides and specific config: ```json { - "debug": true, - "logging": { - "level": "DEBUG" + "application": { + "global": { + "maintenance": false, + "message": "We are in maintenance mode, back shortly", + "timezone": "UTC" + }, + "secret": "${env:APPLICATION_SECRET}" }, - "database": { - "default": "mysql", - "connections": { - "mysql": { - "host": "localhost", - "database": "myapp_dev", - "username": "${env:DB_USER}", - "password": "${env:DB_PASS}" + "logger": { + "level": "debug", + "driver": "file", + "drivers": { + "file": { + "file_path": "storage/log", + "file_format": "Y-m-d", + "line_format": "[%datetime%] [%channel%] [%level_name%] %message% %context%\n", + "line_datetime": "Y-m-d H:i:s.v e" + } + } + }, + "connections": { + "mysql": { + "type": "Pdo", + "driver": "mysql", + "schema": "${env:DB_DATABASE}", + "host": "${env:DB_HOST}", + "port": "${env:DB_PORT}", + "username": "${env:DB_USERNAME}", + "password": "${env:DB_PASSWORD}", + "charset": "UTF8", + "options": { + "ATTR_PERSISTENT": false, + "ATTR_ERRMODE": "ERRMODE_EXCEPTION", + "ATTR_AUTOCOMMIT": false } } } } ``` +> **Logger `line_format` and line endings:** On Linux, Docker, and Unix systems the file driver may not append a newline after each entry. Add `\n` at the end of `line_format` to ensure each log entry ends with a newline. On Windows this is not required but harmless. + ### `config-prod.json` Production settings with hardened defaults. diff --git a/doc/Getting-Started/Quick-Start.md b/doc/Getting-Started/Quick-Start.md index 4d93bbf..7074a00 100644 --- a/doc/Getting-Started/Quick-Start.md +++ b/doc/Getting-Started/Quick-Start.md @@ -38,6 +38,30 @@ mkdir -p src/app/Config mkdir public ``` +## Set Your Application Identity + +Create `src/app/Config/version.json`: + +```json +{ + "application": { + "code": "my-first-app", + "name": "My First App", + "version": "0.1.0" + } +} +``` + +The framework loads this file automatically at startup. Access the values at runtime: + +```php +app()->getAppCode(); // "my-first-app" +app()->getAppName(); // "My First App" +app()->getAppVersion(); // "0.1.0" +``` + +> `code` is also used as the Monolog log channel name and storage path identifier. + ## Create Your First Controller Create `src/app/Controllers/WelcomeController.php`: diff --git a/doc/Getting-Started/Your-First-App.md b/doc/Getting-Started/Your-First-App.md index 7557465..679e03a 100644 --- a/doc/Getting-Started/Your-First-App.md +++ b/doc/Getting-Started/Your-First-App.md @@ -30,6 +30,22 @@ mkdir -p public mkdir -p storage/logs ``` +## Step 0: Set Your Application Identity + +Create `src/app/Config/version.json`: + +```json +{ + "application": { + "code": "task-api", + "name": "Task Management API", + "version": "1.0.0" + } +} +``` + +The framework loads this file automatically at startup. `code` is used as the Monolog log channel name and storage path identifier. Access the values at runtime via `app()->getAppCode()`, `app()->getAppName()`, and `app()->getAppVersion()`. + ## Step 1: Create Your First Controller Create `src/app/Controllers/TaskController.php`: @@ -359,33 +375,38 @@ namespace App; ## Step 5: Create Configuration -Create `src/app/Config/config.json`: +Create `src/app/Config/config-dev.json`: ```json { - "app": { - "name": "Task API", - "version": "1.0.0", - "timezone": "UTC" + "application": { + "global": { + "maintenance": false, + "message": "We are in maintenance mode, back shortly", + "timezone": "UTC" + }, + "secret": "${env:APPLICATION_SECRET}" }, - "logging": { - "default": "monolog", - "level": "INFO" + "logger": { + "level": "debug", + "driver": "file", + "drivers": { + "php": { + "line_format": "[%channel%] [%level_name%] %message% %context%\n", + "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%\n", + "line_datetime": "Y-m-d H:i:s.v e" + } + } } } ``` -Create `src/app/Config/config-dev.json`: - -```json -{ - "debug": true, - "logging": { - "level": "DEBUG", - "path": "storage/logs/app.log" - } -} -``` +> **`line_format` and line endings:** Append `\n` to `line_format` in the `file` driver to ensure each log entry ends with a newline on Linux, Docker, and Unix systems. On Windows this is not required but harmless. ## Step 6: Start the Development Server diff --git a/doc/User-Guide/Configuration.md b/doc/User-Guide/Configuration.md index 530f68e..ddcb99e 100644 --- a/doc/User-Guide/Configuration.md +++ b/doc/User-Guide/Configuration.md @@ -2,6 +2,32 @@ SPIN Framework uses a JSON-based configuration system that's loaded at runtime and provides easy access through helper functions. +## Application Identity (`version.json`) + +Every Spin application must have `src/app/Config/version.json`. The framework loads it automatically at startup — before any `config-{env}.json` — and exposes the values through the `app()` helper: + +```json +{ + "application": { + "code": "my-app", + "name": "My Application", + "version": "1.0.0" + } +} +``` + +| Field | Purpose | +|-------|---------| +| `code` | Machine identifier — used as Monolog log channel name and shared-storage path suffix | +| `name` | Human-readable application label | +| `version` | Semver version string | + +```php +app()->getAppCode(); // "my-app" +app()->getAppName(); // "My Application" +app()->getAppVersion(); // "1.0.0" +``` + ## 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. @@ -36,13 +62,13 @@ _Configuration files support `${env:}` macros for environment variables "driver": "php", "drivers": { "php": { - "line_format": "[%channel%] [%level_name%] %message% %context%", + "line_format": "[%channel%] [%level_name%] %message% %context%\n", "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_format": "[%datetime%] [%channel%] [%level_name%] %message% %context%\n", "line_datetime": "Y-m-d H:i:s.v e" } } @@ -239,13 +265,13 @@ Missing variables with no inline default resolve to an empty string. "driver": "php", // Driver name "drivers": { "php": { - "line_format": "[%channel%] [%level_name%] %message% %context%", + "line_format": "[%channel%] [%level_name%] %message% %context%\n", "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_format": "[%datetime%] [%channel%] [%level_name%] %message% %context%\n", "line_datetime": "Y-m-d H:i:s.v e" } } @@ -253,6 +279,8 @@ Missing variables with no inline default resolve to an empty string. } ``` +> **`line_format` and line endings:** The `\n` at the end of `line_format` ensures each log entry is terminated with a newline. On Linux, Docker, and Unix systems this is required for the file driver to produce readable log files. On Windows the newline is written automatically, but the `\n` is harmless. + ### Cache Configuration ```json diff --git a/package.json b/package.json index 897b5cc..5961af8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "spin-framework", "title": "Spin Framework", - "version": "0.0.40", + "version": "0.0.41", "homepage": "https://github.com/Celarius/spin-framework", "description": "A super lightweight PHP UI/REST Framework", "author": { diff --git a/src/Cache/Adapters/Redis.php b/src/Cache/Adapters/Redis.php index 261940e..3b01e84 100644 --- a/src/Cache/Adapters/Redis.php +++ b/src/Cache/Adapters/Redis.php @@ -89,13 +89,25 @@ protected function disconnect(): bool public function get($key, mixed $default = null): mixed { $result = $this->redisClient->get($key); - if ($result) { - if (\is_int($result)) { - return $result; - } - return unserialize($result); + + # Missing key — Predis returns null for a non-existent key + if ($result === null) { + return $default; + } + + # Raw integer — stored un-serialized by set() and by native inc()/dec() + $asInt = \filter_var($result, \FILTER_VALIDATE_INT); + if ($asInt !== false) { + return $asInt; } - return $default; + + # Everything else is serialized; serialize(false) is a valid stored value + $value = @\unserialize($result); + if ($value === false && $result !== \serialize(false)) { + return $default; + } + + return $value; } /** diff --git a/tests/Cache/Adapters/ApcuTest.php b/tests/Cache/Adapters/ApcuTest.php index b1e99b5..fa36789 100644 --- a/tests/Cache/Adapters/ApcuTest.php +++ b/tests/Cache/Adapters/ApcuTest.php @@ -3,6 +3,7 @@ namespace Spin\tests\Core; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\SimpleCache\InvalidArgumentException; use Spin\Cache\Adapters\Apcu; use Spin\Exceptions\CacheException; @@ -374,6 +375,41 @@ public function testComplexDataTypes(): void $this->assertFalse($this->cacheObj->get('bool_false')); } + /** + * Regression for #78 — every supported value type must survive a + * set()/get() round-trip with its type and value intact. + */ + #[DataProvider('roundTripValueProvider')] + public function testValueRoundTrip(string $label, mixed $value): void + { + $key = 'roundtrip_' . $label; + + $this->assertTrue($this->cacheObj->set($key, $value)); + $this->assertSame($value, $this->cacheObj->get($key), "Round-trip failed for: {$label}"); + } + + /** + * @return array + */ + public static function roundTripValueProvider(): array + { + return [ + 'positive_int' => ['positive_int', 12345], + 'zero_int' => ['zero_int', 0], + 'negative_int' => ['negative_int', -7], + 'large_int' => ['large_int', \time()], + 'float' => ['float', 3.14], + 'bool_true' => ['bool_true', true], + 'bool_false' => ['bool_false', false], + 'empty_string' => ['empty_string', ''], + 'zero_string' => ['zero_string', '0'], + 'numeric_string'=> ['numeric_string', '12345'], + 'string' => ['string', 'hello'], + 'null' => ['null', null], + 'array' => ['array', ['a' => 1, 'b' => [2, 3]]], + ]; + } + public function testKeyValidation(): void { // Test with empty key diff --git a/tests/Cache/Adapters/RedisTest.php b/tests/Cache/Adapters/RedisTest.php index a28be02..38adc3c 100644 --- a/tests/Cache/Adapters/RedisTest.php +++ b/tests/Cache/Adapters/RedisTest.php @@ -3,6 +3,7 @@ namespace Spin\tests\Core; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\SimpleCache\InvalidArgumentException; use Spin\Cache\Adapters\Redis; use Spin\Exceptions\CacheException; @@ -344,6 +345,43 @@ public function testDriverName(): void $this->assertEquals('Redis', $driver); } + /** + * Regression for #78 — every supported value type must survive a + * set()/get() round-trip with its type and value intact. + * + * @throws InvalidArgumentException + */ + #[DataProvider('roundTripValueProvider')] + public function testValueRoundTrip(string $label, mixed $value): void + { + $key = 'roundtrip_' . $label; + + $this->assertTrue($this->cacheObj->set($key, $value)); + $this->assertSame($value, $this->cacheObj->get($key), "Round-trip failed for: {$label}"); + } + + /** + * @return array + */ + public static function roundTripValueProvider(): array + { + return [ + 'positive_int' => ['positive_int', 12345], + 'zero_int' => ['zero_int', 0], + 'negative_int' => ['negative_int', -7], + 'large_int' => ['large_int', \time()], + 'float' => ['float', 3.14], + 'bool_true' => ['bool_true', true], + 'bool_false' => ['bool_false', false], + 'empty_string' => ['empty_string', ''], + 'zero_string' => ['zero_string', '0'], + 'numeric_string'=> ['numeric_string', '12345'], + 'string' => ['string', 'hello'], + 'null' => ['null', null], + 'array' => ['array', ['a' => 1, 'b' => [2, 3]]], + ]; + } + /** * Test that values persist across different instances *