diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 100644 index 11885b0..0000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1,32 +0,0 @@ -# Project - -PHP library (tiny-blocks ecosystem). Self-contained package: immutable models, zero infrastructure -dependencies in core, small public surface area. Public API at `src/` root; implementation details -under `src/Internal/`. - -## Rules - -All coding standards, architecture, naming, testing, and documentation conventions -are defined in `rules/`. Read the applicable rule files before generating any code or documentation. - -## Commands - -- `make test` — run tests with coverage. -- `make mutation-test` — run mutation testing (Infection). -- `make review` — run lint. -- `make help` — list all available commands. - -## Post-change validation - -After any code change, run `make review`, `make test`, and `make mutation-test`. -If any fails, iterate on the fix while respecting all project rules until all pass. -Never deliver code that breaks lint, tests, or leaves surviving mutants. - -## File formatting - -Every file produced or modified must: - -- Use **LF** line endings. Never CRLF. -- Have no trailing whitespace on any line. -- End with a single trailing newline. -- Have no consecutive blank lines (max one blank line between blocks). diff --git a/.claude/rules/github-workflows.md b/.claude/rules/github-workflows.md deleted file mode 100644 index a369ba4..0000000 --- a/.claude/rules/github-workflows.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -description: Naming, ordering, inputs, security, and structural rules for all GitHub Actions workflow files. -paths: - - ".github/workflows/**/*.yml" - - ".github/workflows/**/*.yaml" ---- - -# Workflows - -Structural and stylistic rules for GitHub Actions workflow files. Refer to `shell-scripts.md` for Bash conventions used -inside `run:` steps, and to `terraforms.md` for Terraform conventions used in `terraform/`. - -## Pre-output checklist - -Verify every item before producing any workflow YAML. If any item fails, revise before outputting. - -1. File name follows the convention: `ci-.yml` for reusable CI, `cd-.yml` for dispatch CD. -2. `name` field follows the pattern `CI — ` or `CD — `, using sentence case after the dash - (e.g., `CD — Run migration`, not `CD — Run Migration`). -3. Reusable workflows use `workflow_call` trigger. CD workflows use `workflow_dispatch` trigger. -4. Each workflow has a single responsibility. CI tests code. CD deploys it. Never combine both. -5. Every input has a `description` field. Descriptions use American English and end with a period. -6. Input names use `kebab-case`: `service-name`, `dry-run`, `skip-build`. -7. Inputs are ordered: required first, then optional. Each group by **name length ascending**. -8. Choice input options are in **alphabetical order**. -9. `env`, `outputs`, and `with` entries are ordered by **key length ascending**. -10. `permissions` keys are ordered by **key length ascending** (`contents` before `id-token`). -11. Top-level workflow keys follow canonical order: `name`, `on`, `concurrency`, `permissions`, `env`, `jobs`. -12. Job-level properties follow canonical order: `if`, `name`, `needs`, `uses`, `with`, `runs-on`, - `environment`, `timeout-minutes`, `strategy`, `outputs`, `permissions`, `env`, `steps`. -13. All other YAML property names within a block are ordered by **name length ascending**. -14. Jobs follow execution order: `load-config` → `lint` → `test` → `build` → `deploy`. -15. Step names start with a verb and use sentence case: `Setup PHP`, `Run lint`, `Resolve image tag`. -16. Runtime versions are resolved from the service repo's native dependency file (`composer.json`, `go.mod`, - `package.json`). No version is hardcoded in any workflow. -17. Service-specific overrides live in a pipeline config file (e.g., `.pipeline.yml`) in the service repo, - not in the workflows repository. -18. The `load-config` job reads the pipeline config file at runtime with safe fallback to defaults when absent. -19. Top-level `permissions` defaults to read-only (`contents: read`). Jobs escalate only the permissions they - need. -20. AWS authentication uses OIDC federation exclusively. Static access keys are forbidden. -21. Secrets are passed via `secrets: inherit` from callers. No secret is hardcoded. -22. Sensitive values fetched from SSM are masked with `::add-mask::` before assignment. -23. Third-party actions are pinned to the latest available full commit SHA with a version comment: - `uses: aws-actions/configure-aws-credentials@ # v4.0.2`. Always verify the latest - version before generating a workflow. -24. First-party actions (`actions/*`) are pinned to the latest major version tag available: - `actions/checkout@v4`. Always check for the most recent major version before generating a workflow. -25. Production deployments require GitHub Environments protection rules (manual approval). -26. Every job sets `timeout-minutes` to prevent indefinite hangs. CI jobs: 10–15 minutes. CD jobs: 20–30 - minutes. Adjust only with justification in a comment. -27. CI workflows set `concurrency` with `group` scoped to the PR and `cancel-in-progress: true` to avoid - redundant runs. -28. CD workflows set `concurrency` with `group` scoped to the environment and `cancel-in-progress: false` to - prevent interrupted deployments. -29. CD workflows use `if: ${{ !cancelled() }}` to allow to deploy after optional build steps. -30. Inline logic longer than 3 lines is extracted to a script in `scripts/ci/` or `scripts/cd/`. - -## Style - -- All text (workflow names, step names, input descriptions, comments) uses American English with correct - spelling and punctuation. Sentences and descriptions end with a period. - -## Callers - -- Callers trigger on `pull_request` targeting `main` only. No `push` trigger. -- Callers in service repos are static (~10 lines) and pass only `service-name` or `app-name`. -- Callers reference workflows with `@main` during development. Pin to a tag or SHA for production. - -## Image tagging - -- CD deploy builds: `-sha-` + `latest`. - -## Migrations - -- Migrations run **before** service deployment (schema first, code second). -- `cd-migrate.yml` supports `dry-run` mode (`flyway validate`) for pre-flight checks. -- Database credentials are fetched from SSM at runtime, never stored in workflow files. diff --git a/.claude/rules/php-library-code-style.md b/.claude/rules/php-library-code-style.md deleted file mode 100644 index 7ec196e..0000000 --- a/.claude/rules/php-library-code-style.md +++ /dev/null @@ -1,154 +0,0 @@ ---- -description: Pre-output checklist, naming, typing, complexity, and PHPDoc rules for all PHP files in libraries. -paths: - - "src/**/*.php" - - "tests/**/*.php" ---- - -# Code style - -Semantic code rules for all PHP files. Formatting rules (PSR-1, PSR-4, PSR-12, line length) are enforced by `phpcs.xml` -and are not repeated here. Refer to `php-library-modeling.md` for library modeling rules. - -## Pre-output checklist - -Verify every item before producing any PHP code. If any item fails, revise before outputting. - -1. `declare(strict_types=1)` is present. -2. All classes are `final readonly` by default. Use `class` (without `final` or `readonly`) only when the class is - designed as an extension point for consumers (e.g., `Collection`, `ValueObject`). Use `final class` without - `readonly` only when the parent class is not readonly (e.g., extending a third-party abstract class). -3. All parameters, return types, and properties have explicit types. -4. Constructor property promotion is used. -5. Named arguments are used at call sites for own code, tests, and third-party library methods (e.g., tiny-blocks). - Never use named arguments on native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`, - `iterator_to_array`, `sprintf`, `implode`, etc.) or PHPUnit assertions (`assertEquals`, `assertSame`, - `assertTrue`, `expectException`, etc.). -6. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead. -7. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of `$acc`. -8. No generic identifiers exist. Use domain-specific names instead: - `$data` → `$payload`, `$value` → `$totalAmount`, `$item` → `$element`, - `$info` → `$currencyDetails`, `$result` → `$conversionOutcome`. -9. No raw arrays exist where a typed collection or value object is available. Use the `tiny-blocks/collection` - fluent API (`Collection`, `Collectible`) when data is `Collectible`. Use `createLazyFrom` when elements are - consumed once. Raw arrays are acceptable only for primitive configuration data, variadic pass-through, and - interop at system boundaries. See "Collection usage" below for the full rule and example. -10. No private methods exist except private constructors for factory patterns. Inline trivial logic at the call site - or extract it to a collaborator or value object. -11. Members are ordered: constants first, then constructor, then static methods, then instance methods. Within each - group, order by body size ascending (number of lines between `{` and `}`). Constants and enum cases, which have - no body, are ordered by name length ascending. -12. Constructor parameters are ordered by parameter name length ascending (count the name only, without `$` or type), - except when parameters have an implicit semantic order (e.g., `$start/$end`, `$from/$to`, `$startAt/$endAt`), - which takes precedence. Parameters with default values go last, regardless of name length. The same rule - applies to named arguments at call sites. - Example: `$id` (2) → `$value` (5) → `$status` (6) → `$precision` (9). -13. Time and space complexity are first-class design concerns. - - No `O(N²)` or worse time complexity exists unless the problem inherently requires it and the cost is - documented in PHPDoc on the interface method. - - Space complexity is kept minimal: prefer lazy/streaming pipelines (`createLazyFrom`) over materializing - intermediate collections. - - Never re-iterate the same source; fuse stages when possible. - - Public interface methods document time and space complexity in Big O form (see "PHPDoc" section). -14. No logic is duplicated across two or more places (DRY). -15. No abstraction exists without real duplication or isolation need (KISS). -16. All identifiers, comments, and documentation are written in American English. -17. No justification comments exist (`// NOTE:`, `// REASON:`, etc.). Code speaks for itself. -18. `// TODO: ` is used when implementation is unknown, uncertain, or intentionally deferred. - Never leave silent gaps. -19. All class references use `use` imports at the top of the file. Fully qualified names inline are prohibited. -20. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports. -21. Never create public methods, constants, or classes in `src/` solely to serve tests. If production code does not - need it, it does not exist. -22. Always use the most current and clean syntax available in the target PHP version. Prefer match to switch, - first-class callables over `Closure::fromCallable()`, readonly promotion over manual assignment, enum methods - over external switch/if chains, named arguments over positional ambiguity (except where excluded by rule 5), - and `Collection::map` over foreach accumulation. -23. No vertical alignment of types in parameter lists or property declarations. Use a single space between - type and variable name. Never pad with extra spaces to align columns: - `public OrderId $id` — not `public OrderId $id`. -24. Opening brace `{` follows PSR-12: on a **new line** for classes, interfaces, traits, enums, and methods - (including constructors); on the **same line** for closures and control structures (`if`, `for`, `foreach`, - `while`, `switch`, `match`, `try`). -25. Never pass an argument whose value equals the parameter's default. Omit the argument entirely. - Example — `toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE)`: - `$collection->toArray(keyPreservation: KeyPreservation::PRESERVE)` → `$collection->toArray()`. - Only pass the argument when the value differs from the default. -26. No trailing comma in any multi-line list. This applies to parameter lists (constructors, methods, - closures), argument lists at call sites, array literals, match arms, and any other comma-separated - multi-line structure. The last element never has a comma after it. PHP accepts trailing commas in - parameter lists, but this project prohibits them for visual consistency. - Example — correct: - ``` - new Precision( - value: 2, - rounding: RoundingMode::HALF_UP - ); - ``` - Example — prohibited: - ``` - new Precision( - value: 2, - rounding: RoundingMode::HALF_UP, - ); - ``` - -## Casing conventions - -- Internal code (variables, methods, classes): **`camelCase`**. -- Constants and enum-backed values when representing codes: **`SCREAMING_SNAKE_CASE`**. - -## Naming - -- Names describe **what** in domain terms, not **how** technically: `$monthlyRevenue` instead of `$calculatedValue`. -- Generic technical verbs are avoided. See `php-library-modeling.md` — Nomenclature. -- Booleans use predicate form: `isActive`, `hasPermission`, `wasProcessed`. -- Collections are always plural: `$orders`, `$lines`. -- Methods returning bool use prefixes: `is`, `has`, `can`, `was`, `should`. - -## Comparisons - -1. Null checks: use `is_null($variable)`, never `$variable === null`. -2. Empty string checks on typed `string` parameters: use `$variable === ''`. Avoid `empty()` on typed strings - because `empty('0')` returns `true`. -3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`): use `empty($variable)`. - -## American English - -All identifiers, enum values, comments, and error codes use American English spelling: -`canceled` (not `cancelled`), `organization` (not `organisation`), `initialize` (not `initialise`), -`behavior` (not `behaviour`), `modeling` (not `modelling`), `labeled` (not `labelled`), -`fulfill` (not `fulfil`), `color` (not `colour`). - -## PHPDoc - -- PHPDoc is restricted to interfaces only, documenting obligations, `@throws`, and complexity. -- Never add PHPDoc to concrete classes. -- Document `@throws` for every exception the method may raise. -- Document time and space complexity in Big O form. When a method participates in a fused pipeline (e.g., collection - pipelines), express cost as a two-part form: call-site cost + fused-pass contribution. Include a legend defining - variables (e.g., `N` for input size, `K` for number of stages). - -## Collection usage - -When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array functions such as -`array_map`, `array_filter`, `iterator_to_array`, or `foreach` + accumulation. The same applies to `filter()`, -`reduce()`, `each()`, and all other `Collectible` operations. Chain them fluently. Never materialize with -`iterator_to_array` to then pass into a raw `array_*` function. - -**Prohibited — `array_map` + `iterator_to_array` on a Collectible:** - -```php -$names = array_map( - static fn(Element $element): string => $element->name(), - iterator_to_array($collection) -); -``` - -**Correct — fluent chain with `map()` + `toArray()`:** - -```php -$names = $collection - ->map(transformations: static fn(Element $element): string => $element->name()) - ->toArray(keyPreservation: KeyPreservation::DISCARD); -``` diff --git a/.claude/rules/php-library-documentation.md b/.claude/rules/php-library-documentation.md deleted file mode 100644 index d7ac6da..0000000 --- a/.claude/rules/php-library-documentation.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -description: Standards for README files and all project documentation in PHP libraries. -paths: - - "**/*.md" ---- - -# Documentation - -## README - -1. Include an anchor-linked table of contents. -2. Start with a concise one-line description of what the library does. -3. Include a **badges** section (license, build status, coverage, latest version, PHP version). -4. Provide an **Overview** section explaining the problem the library solves and its design philosophy. -5. **Installation** section: Composer command (`composer require vendor/package`). -6. **How to use** section: complete, runnable code examples covering the primary use cases. Each example - includes a brief heading describing what it demonstrates. -7. If the library exposes multiple entry points, strategies, or container types, document each with its own - subsection and example. -8. **FAQ** section: include entries for common pitfalls, non-obvious behaviors, or design decisions that users - frequently ask about. Each entry is a numbered question as heading (e.g., `### 01. Why does X happen?`) - followed by a concise explanation. Only include entries that address real confusion points. -9. **License** and **Contributing** sections at the end. -10. Write strictly in American English. See `php-library-code-style.md` American English section for spelling - conventions. - -## Structured data - -1. When documenting constructors, factory methods, or configuration options with more than 3 parameters, - use tables with columns: Parameter, Type, Required, Description. -2. Prefer tables to prose for any structured information. - -## Style - -1. Keep language concise and scannable. -2. Never include placeholder content (`TODO`, `TBD`). -3. Code examples must be syntactically correct and self-contained. -4. Code examples include every `use` statement needed to compile. Each example stands alone — copyable into - a fresh file without modification. -5. Do not document `Internal/` classes or private API. Only document what consumers interact with. diff --git a/.claude/rules/php-library-modeling.md b/.claude/rules/php-library-modeling.md deleted file mode 100644 index bedb733..0000000 --- a/.claude/rules/php-library-modeling.md +++ /dev/null @@ -1,163 +0,0 @@ ---- -description: Library modeling rules — folder structure, public API boundary, naming, value objects, exceptions, enums, extension points, and complexity. -paths: - - "src/**/*.php" ---- - -# Library modeling - -Libraries are self-contained packages. The core has no dependency on frameworks, databases, or I/O. Refer to -`php-library-code-style.md` for the pre-output checklist applied to all PHP code. - -## Folder structure - -``` -src/ -├── .php # Primary contract for consumers -├── .php # Main implementation or extension point -├── .php # Public enum -├── Contracts/ # Interfaces for data returned to consumers -├── Internal/ # Implementation details (not part of public API) -│ ├── .php -│ └── Exceptions/ # Internal exception classes -├── / # Feature-specific subdirectory when needed -└── Exceptions/ # Public exception classes (when part of the API) -``` - -Never use `Models/`, `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. - -## Public API boundary - -Only interfaces, extension points, enums, and thin orchestration classes live at the `src/` root. These classes -define the contract consumers interact with and delegate all real work to collaborators inside `src/Internal/`. -If a class contains substantial logic (algorithms, state machines, I/O), it belongs in `Internal/`, not at the root. - -The `Internal/` namespace signals classes that are implementation details. Consumers must not depend on them. -Breaking changes inside `Internal/` are not semver-breaking for the library. - -## Nomenclature - -1. Every class, property, method, and exception name reflects the **concept** the library represents. A math library - uses `Precision`, `RoundingMode`; a money library uses `Currency`, `Amount`; a collection library uses - `Collectible`, `Order`. -2. Name classes after what they represent: `Money`, `Color`, `Pipeline` — not after what they do technically. -3. Name methods after the operation in the library's vocabulary: `add()`, `convertTo()`, `splitAt()`. - -### Always banned - -These names carry zero semantic content. Never use them anywhere, as class suffixes, prefixes, or method names: - -- `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`. -- `Exception` as a class suffix (e.g., `FooException` — use `Foo` when it already extends a native exception). - -### Anemic verbs (banned by default) - -These verbs hide what is actually happening behind a generic action. Banned unless the verb **is** the operation -that constitutes the library's reason to exist (e.g., a JSON parser may have `parse()`; a hashing library may -have `compute()`): - -- `ensure`, `validate`, `check`, `verify`, `assert`, `mark`, `enforce`, `sanitize`, `normalize`, `compute`, - `transform`, `parse`. - -When in doubt, prefer the domain operation name. `Password::hash()` beats `Password::compute()`; `Email::parse()` -is fine in a parser library but suspicious elsewhere (use `Email::from()` instead). - -### Architectural roles (allowed with justification) - -These names describe a role the library offers as a building block. Acceptable when the class **is** that role -(e.g., `EventHandler` in an events library, `CacheManager` in a cache library, `Upcaster` in an event-sourcing -library). Not acceptable on domain objects inside the library (value objects, enums, contract interfaces): - -- `Manager`, `Handler`, `Processor`, `Service`, and their verb forms `process`, `handle`, `execute`. - -The test: if the consumer instantiates or extends this class to integrate with the library, the role name is -legitimate. If the class models a concept the consumer manipulates (a money amount, a country code, a color), -the role name is wrong. - -## Value objects - -1. Are immutable: no setters, no mutation after construction. Operations return new instances. -2. Compare by value, not by reference. -3. Validate invariants in the constructor and throw on invalid input. -4. Have no identity field. -5. Use static factory methods (e.g., `from`, `of`, `zero`) with a private constructor when multiple creation paths - exist. The factory name communicates the semantic intent. - -## Exceptions - -1. Every failure throws a **dedicated exception class** named after the invariant it guards — never - `throw new DomainException('...')`, `throw new InvalidArgumentException('...')`, - `throw new RuntimeException('...')`, or any other generic native exception thrown directly. If the invariant - is worth throwing for, it is worth a named class. -2. Dedicated exception classes **extend** the appropriate native PHP exception (`DomainException`, - `InvalidArgumentException`, `OverflowException`, etc.) — the native class is the parent, never the thing that - is thrown. Consumers that catch the broad standard types continue to work; consumers that need precise handling - can catch the specific classes. -3. Exceptions are pure: no transport-specific fields (`code` populated with HTTP status, formatted `message` meant - for end-user display). Formatting to any transport happens at the consumer's boundary, not inside the library. -4. Exceptions signal invariant violations only, not control flow. -5. Name the class after the invariant violated, never after the technical type: - - `PrecisionOutOfRange` — not `InvalidPrecisionException`. - - `CurrencyMismatch` — not `BadCurrencyException`. - - `ContainerWaitTimeout` — not `TimeoutException`. -6. A descriptive `message` argument is allowed and encouraged when it carries **debugging context** — the violating - value, the boundary that was crossed, the state the library was in. The class name identifies the invariant; - the message describes the specific violation for stack traces and test assertions. Do not build messages meant - for end-user display or transport rendering. Keep them short, factual, and in American English. -7. Public exceptions live in `src/Exceptions/`. Internal exceptions live in `src/Internal/Exceptions/`. - -**Prohibited** — throwing a native exception directly: - -```php -if ($value < 0) { - throw new InvalidArgumentException('Precision cannot be negative.'); -} -``` - -**Correct** — dedicated class, no message (class name is sufficient): - -```php -// src/Exceptions/PrecisionOutOfRange.php -final class PrecisionOutOfRange extends InvalidArgumentException -{ -} - -// at the callsite -if ($value < 0) { - throw new PrecisionOutOfRange(); -} -``` - -**Correct** — dedicated class with debugging context: - -```php -if ($value < 0 || $value > 16) { - throw new PrecisionOutOfRange(sprintf('Precision must be between 0 and 16, got %d.', $value)); -} -``` - -## Enums - -1. Are PHP backed enums. -2. Include methods when they carry vocabulary meaning (e.g., `Order::ASCENDING_KEY`, `RoundingMode::apply()`). -3. Live at the `src/` root when public. Enums used only by internals live in `src/Internal/`. - -## Extension points - -1. When a class is designed to be extended by consumers (e.g., `Collection`, `ValueObject`), it uses `class` instead - of `final readonly class`. All other classes use `final readonly class`. -2. Extension point classes use a private constructor with static factory methods (`createFrom`, `createFromEmpty`) - as the only creation path. -3. Internal state is injected via the constructor and stored in a `private readonly` property. - -## Time and space complexity - -1. Every public method has predictable, documented complexity. Document Big O in PHPDoc on the interface - (see `php-library-code-style.md`, "PHPDoc" section). -2. Algorithms run in `O(N)` or `O(N log N)` unless the problem inherently requires worse. `O(N²)` or worse must - be justified and documented. -3. Prefer lazy/streaming evaluation over materializing intermediate results. In pipeline-style libraries, fuse - stages so a single pass suffices. -4. Memory usage is bounded and proportional to the output, not to the sum of intermediate stages. -5. Validate complexity claims with benchmarks against a reference implementation when optimizing critical paths. - Parity testing against the reference library is the validation standard for optimization work. diff --git a/.claude/rules/php-library-testing.md b/.claude/rules/php-library-testing.md deleted file mode 100644 index 610b928..0000000 --- a/.claude/rules/php-library-testing.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -description: BDD Given/When/Then structure, PHPUnit conventions, test organization, and fixture rules for PHP libraries. -paths: - - "tests/**/*.php" ---- - -# Testing conventions - -Framework: **PHPUnit**. Refer to `php-library-code-style.md` for the code style checklist, which also applies to -test files. - -## Structure: Given/When/Then (BDD) - -Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments without exception. - -### Happy path example - -```php -public function testAddMoneyWhenSameCurrencyThenAmountsAreSummed(): void -{ - /** @Given two money instances in the same currency */ - $ten = Money::of(amount: 1000, currency: Currency::BRL); - $five = Money::of(amount: 500, currency: Currency::BRL); - - /** @When adding them together */ - $total = $ten->add(other: $five); - - /** @Then the result contains the sum of both amounts */ - self::assertEquals(expected: 1500, actual: $total->amount()); -} -``` - -### Exception example - -When testing that an exception is thrown, place `@Then` (expectException) **before** `@When`. PHPUnit requires this -ordering. - -```php -public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void -{ - /** @Given two money instances in different currencies */ - $brl = Money::of(amount: 1000, currency: Currency::BRL); - $usd = Money::of(amount: 500, currency: Currency::USD); - - /** @Then an exception indicating currency mismatch should be thrown */ - $this->expectException(CurrencyMismatch::class); - - /** @When trying to add money with different currencies */ - $brl->add(other: $usd); -} -``` - -Use `@And` for complementary preconditions or actions within the same scenario, avoiding consecutive `@Given` or -`@When` tags. - -## Rules - -1. Include exactly one `@When` per test. Two actions require two tests. -2. Test only the public API. Never assert on private state or `Internal/` classes directly. -3. Never mock internal collaborators. Use real objects. Use test doubles only at system boundaries (filesystem, - clock, network) when the library interacts with external resources. -4. Name tests to describe behavior, not method names. -5. Never include conditional logic inside tests. -6. Include one logical concept per `@Then` block. -7. Maintain strict independence between tests. No inherited state. -8. Use domain-specific model classes in `tests/Models/` for test fixtures that represent domain concepts - (e.g., `Amount`, `Invoice`, `Order`). -9. Use mock classes in `tests/Mocks/` (or `tests/Unit/Mocks/`) for test doubles of system boundaries - (e.g., `ClientMock`, `ExecutionCompletedMock`). -10. Exercise invariants and edge cases through the library's public entry point. Create a dedicated test class - for an internal model only when the condition cannot be reached through the public API. -11. Never use `/** @test */` annotation. Test methods are discovered by the `test` prefix in the method name. -12. Never use named arguments on PHPUnit assertions (`assertEquals`, `assertSame`, `assertTrue`, - `expectException`, etc.). Pass arguments positionally. - -## Test setup and fixtures - -1. **One annotation = one statement.** Each `@Given` or `@And` block contains exactly one annotation line - followed by one expression or assignment. Never place multiple variable declarations or object - constructions under a single annotation. -2. **No intermediate variables used only once.** If a value is consumed in a single place, inline it at the - call site. Chain method calls when the intermediate state is not referenced elsewhere - (e.g., `Money::of(...)->add(...)` instead of `$money = Money::of(...); $money->add(...);`). -3. **No private or helper methods in test classes.** The only non-test methods allowed are data providers. - If setup logic is complex enough to extract, it belongs in a dedicated fixture class, not in a - private method on the test class. -4. **Domain terms in variables and annotations.** Never use technical testing jargon (`$spy`, `$mock`, - `$stub`, `$fake`, `$dummy`) as variable or property names. Use the domain concept the object - represents: `$collection`, `$amount`, `$currency`, `$sortedElements`. Class names like - `ClientMock` or `GatewaySpy` are acceptable — the variable holding the instance is what matters. -5. **Annotations use domain language.** Write `/** @Given a collection of amounts */`, not - `/** @Given a mocked collection in test state */`. The annotation describes the domain - scenario, not the technical setup. - -## Test organization - -``` -tests/ -├── Models/ # Domain-specific fixtures reused across tests -├── Mocks/ # Test doubles for system boundaries -├── Unit/ # Unit tests for public API -│ └── Mocks/ # Alternative location for test doubles -├── Integration/ # Tests requiring real external resources (Docker, filesystem) -└── bootstrap.php # Test bootstrap when needed -``` - -`tests/Integration/` is only present when the library interacts with infrastructure. - -## Coverage and mutation testing - -1. Line and branch coverage must be **100%**. No annotations (`@codeCoverageIgnore`), attributes, or configuration - that exclude code from coverage are allowed. -2. All mutations reported by Infection must be **killed**. Never ignore or suppress mutants via `infection.json.dist` - or any other mechanism. -3. If a line or mutation cannot be covered or killed, it signals a design problem in the production code. Refactor - the code to make it testable, do not work around the tool. diff --git a/.editorconfig b/.editorconfig index 73e3c9a..be5640e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,6 +5,7 @@ charset = utf-8 end_of_line = lf indent_size = 4 indent_style = space +max_line_length = 120 insert_final_newline = true trim_trailing_whitespace = true diff --git a/.gitattributes b/.gitattributes index 744a43b..f044953 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,21 +2,18 @@ *.php text diff=php -# Dev-only — excluded from the Packagist tarball +# Keep Claude tooling scripts out of GitHub's language statistics + +# Dev-only, excluded from the Packagist tarball /.github export-ignore /tests export-ignore -/.claude export-ignore /.editorconfig export-ignore /.gitattributes export-ignore /.gitignore export-ignore +/phpcs.xml export-ignore /phpunit.xml export-ignore -/phpunit.xml.dist export-ignore -/phpstan.neon export-ignore /phpstan.neon.dist export-ignore -/phpcs.xml export-ignore -/phpcs.xml.dist export-ignore -/infection.json export-ignore /infection.json.dist export-ignore /Makefile export-ignore -/CONTRIBUTING.md export-ignore -/CHANGES.md export-ignore +/reports export-ignore +/.phpunit.cache export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8ddd1db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Report a bug to help improve the library +labels: bug +--- + +## Description + +A clear and concise description of the bug. + +## Steps to reproduce + +1. +2. +3. + +## Expected behavior + +What should happen. + +## Actual behavior + +What actually happens. + +## Environment + +- PHP version: +- Library version: +- OS: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b344d9e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest a feature for the library +labels: enhancement +--- + +## Problem + +What problem does this feature solve? + +## Proposed solution + +How should the feature work? + +## Alternatives considered + +Other approaches considered. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..7a2c836 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +> Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md). + +## Summary + +What this pull request does. + +## Related issue + +Closes #... + +## Checklist + +- [ ] Tests added or updated. +- [ ] Documentation updated when applicable. +- [ ] `composer review` passes. +- [ ] `composer tests` passes. diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml index d0ba49e..e87e331 100644 --- a/.github/workflows/auto-assign.yml +++ b/.github/workflows/auto-assign.yml @@ -8,12 +8,19 @@ on: types: - opened +concurrency: + group: auto-assign-${{ github.event.issue.number || github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + issues: write + pull-requests: write + jobs: - run: + auto-assign: + name: Auto assign runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write + timeout-minutes: 5 steps: - name: Assign issues and pull requests uses: gustavofreze/auto-assign@2.1.0 @@ -22,4 +29,4 @@ jobs: github_token: '${{ secrets.GITHUB_TOKEN }}' allow_self_assign: 'true' allow_no_assignees: 'true' - assignment_options: 'ISSUE,PULL_REQUEST' \ No newline at end of file + assignment_options: 'ISSUE,PULL_REQUEST' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index aed6dab..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Security checks - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - schedule: - - cron: "0 0 * * *" - -permissions: - actions: read - contents: read - security-events: write - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - language: [ "actions" ] - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.3 - with: - languages: ${{ matrix.language }} - - - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@v4.37.3 diff --git a/.gitignore b/.gitignore index bd5baa3..29546dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,30 @@ -# Agent/IDE -.claude/ -.idea/ -.vscode/ -.cursor/ - -# Composer +# PHP dependencies /vendor/ composer.lock -# PHPUnit / coverage +# Local config overrides (committed baselines are the .dist files) +/phpstan.neon +/infection.json + +# Tooling cache .phpunit.cache/ .phpunit.result.cache -report/ -coverage/ +__pycache__/ +*.pyc + +# Coverage and reports build/ +reports/ +coverage/ +infection.log + +# Editors and agents +.idea/ +.cursor/ +.vscode/ +/.claude/settings.local.json # OS -.DS_Store Thumbs.db +.DS_Store +Desktop.ini diff --git a/Makefile b/Makefile index 07acc3b..90ab50d 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,9 @@ ifeq ($(ARCH),arm64) PLATFORM := --platform=linux/amd64 endif -DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine +TTY := $(shell [ -t 0 ] && echo -it) + +DOCKER_RUN = docker run ${PLATFORM} --rm ${TTY} --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine RESET := \033[0m GREEN := \033[0;32m @@ -16,28 +18,27 @@ YELLOW := \033[0;33m .PHONY: configure configure: ## Configure development environment - @${DOCKER_RUN} composer update --optimize-autoloader - @${DOCKER_RUN} composer normalize + @${DOCKER_RUN} composer configure + +.PHONY: configure-and-update +configure-and-update: ## Configure development environment and update dependencies + @${DOCKER_RUN} composer configure-and-update -.PHONY: test -test: ## Run all tests with coverage +.PHONY: tests +tests: ## Run unit and mutation tests with coverage @${DOCKER_RUN} composer tests .PHONY: test-file test-file: ## Run tests for a specific file (usage: make test-file FILE=ClassNameTest) @${DOCKER_RUN} composer test-file ${FILE} -.PHONY: test-no-coverage -test-no-coverage: ## Run all tests without coverage - @${DOCKER_RUN} composer tests-no-coverage - .PHONY: review -review: ## Run static code analysis +review: ## Run lint and static analysis @${DOCKER_RUN} composer review .PHONY: show-reports -show-reports: ## Open static analysis reports (e.g., coverage, lints) in the browser - @sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html +show-reports: ## Open coverage and mutation reports in the browser + @sensible-browser reports/coverage/coverage-html/index.html reports/coverage/mutation-report.html .PHONY: show-outdated show-outdated: ## Show outdated direct dependencies @@ -46,18 +47,18 @@ show-outdated: ## Show outdated direct dependencies .PHONY: clean clean: ## Remove dependencies and generated artifacts @sudo chown -R ${USER}:${USER} ${PWD} - @rm -rf report vendor .phpunit.cache *.lock + @rm -rf reports vendor .phpunit.cache *.lock .PHONY: help -help: ## Display this help message +help: ## Display this help message @echo "Usage: make [target]" @echo "" @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')" - @grep -E '^(configure):.*?## .*$$' $(MAKEFILE_LIST) \ + @grep -E '^(configure|configure-and-update):.*?## .*$$' $(MAKEFILE_LIST) \ | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')" - @grep -E '^(test|test-file|test-no-coverage):.*?## .*$$' $(MAKEFILE_LIST) \ + @grep -E '^(tests|test-file):.*?## .*$$' $(MAKEFILE_LIST) \ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')" diff --git a/README.md b/README.md index 8172db6..b2a1c06 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,20 @@ # Environment variable -[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/tiny-blocks/environment-variable/blob/main/LICENSE) * [Overview](#overview) * [Installation](#installation) * [How to use](#how-to-use) + + [Creating an environment variable](#creating-an-environment-variable) + + [Conversions](#conversions) + - [Convert to string](#convert-to-string) + - [Convert to integer](#convert-to-integer) + - [Convert to float](#convert-to-float) + - [Convert to boolean](#convert-to-boolean) + + [Check if the environment variable has a value](#check-if-the-environment-variable-has-a-value) + + [Exceptions](#exceptions) + + [Resolution order](#resolution-order) +* [FAQ](#faq) * [License](#license) * [Contributing](#contributing) @@ -13,9 +23,12 @@ ## Overview Provides a type-safe environment variable reader for PHP, wrapping raw values behind a typed accessor with explicit -string, integer, and boolean conversion methods. Supports defaults for missing variables and distinguishes between -absent and empty states. Built to surface configuration errors at read time rather than propagate silent coercions -through the system. +string, integer, float, and boolean conversion methods. Supports defaults for missing variables and distinguishes +between absent and empty states. Built to surface configuration errors at read time rather than propagate silent +coercions through the system. + +Names in the `HTTP_` namespace are read only from sources an HTTP request cannot reach, so a request header is never +mistaken for configuration. See [Resolution order](#resolution-order).
@@ -32,8 +45,13 @@ composer require tiny-blocks/environment-variable ### Creating an environment variable To create and work with environment variables, use the `from` method to get an instance of the environment variable. +When no source holds the variable, the method raises `EnvironmentVariableMissing`. ```php +toString(); +EnvironmentVariable::from(name: 'MY_VAR')->toString(); ``` #### Convert to integer -To convert the environment variable to an integer. +To convert the environment variable to an integer. Values that do not represent an integer, including values above +`PHP_INT_MAX`, raise `EnvironmentValueNotInteger` instead of being silently truncated. + +```php +toInteger(); +``` + +#### Convert to float + +To convert the environment variable to a float. The decimal separator is `.` and thousands separators are not accepted. +Values that do not represent a float raise `EnvironmentValueNotFloat`. ```php +toInteger(); +EnvironmentVariable::from(name: 'MY_VAR')->toFloat(); ``` #### Convert to boolean -To convert the environment variable to a boolean. +To convert the environment variable to a boolean. The accepted values are `1`, `true`, `on`, and `yes` for true, and +`0`, `false`, `off`, `no`, and the empty string for false. Anything else raises `EnvironmentValueNotBoolean`. ```php +toBoolean(); +EnvironmentVariable::from(name: 'MY_VAR')->toBoolean(); ``` ### Check if the environment variable has a value -Checks if the environment variable has a value. Values like `false`, `0`, and `-1` are valid and non-empty. +Checks if the environment variable has a value. Values like `false`, `0`, and `-1` are valid and non-empty. Only an +empty string, a value made of whitespace, and the literal `null` in any casing report no value. ```php +hasValue(); +EnvironmentVariable::from(name: 'MY_VAR')->hasValue(); ``` +### Exceptions + +Every failure raises a dedicated class from `TinyBlocks\EnvironmentVariable\Exceptions`. All of them extend +`InvalidArgumentException`, so a consumer can catch the broad type or the precise one. + +| Exception | Raised by | Condition | +|------------------------------|-------------|--------------------------------------------------------| +| `EnvironmentVariableMissing` | `from` | No source holds the variable. | +| `EnvironmentValueNotInteger` | `toInteger` | The value does not represent an integer. | +| `EnvironmentValueNotFloat` | `toFloat` | The value does not represent a float. | +| `EnvironmentValueNotBoolean` | `toBoolean` | The value is not one of the recognized boolean tokens. | + +Messages carry the variable name, never its value, so a failed conversion on a secret does not leak it into logs or +stack traces. + +### Resolution order + +A value is resolved from the first source that holds it. Non-scalar entries in the superglobals are skipped, so an array +left in `$_ENV` never reaches a conversion method. + +| Order | Source | Read for names in the `HTTP_` namespace | +|-------|---------------------|-------------------------------------------------| +| 1 | `$_ENV` | Yes | +| 2 | `$_SERVER` | No | +| 3 | Process environment | Yes, restricted to the real process environment | + +Under CGI-like servers (PHP-FPM, mod_php, FastCGI), request headers are mapped into `$_SERVER` and into the SAPI +environment under an `HTTP_` prefix, so a `Proxy` request header surfaces as `HTTP_PROXY`. For names in that namespace +the library skips `$_SERVER` and reads only the real process environment, which a request cannot write. A variable +genuinely set in the environment still resolves. See [httpoxy](https://httpoxy.org) for the background. + +
+ +## FAQ + +### 01. Why is a name starting with `HTTP_` not read from `$_SERVER`? + +Because anyone sending a request can write that namespace. Only the request-supplied value is refused, so a variable +genuinely set in the process environment still resolves. See [Resolution order](#resolution-order). + +### 02. Why does `toBoolean` return `false` for an empty value instead of raising? + +The empty string is one of the values PHP's boolean filter recognizes as false, alongside `0`, `off`, and `no`. A +variable declared but left blank therefore reads as `false`. Use `hasValue` first when the difference between blank and +false matters. +
## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..608a034 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,12 @@ +# Security Policy + +## Supported versions + +Only the latest release receives security updates. + +## Reporting a vulnerability + +Report security vulnerabilities privately via +[GitHub Security Advisories](https://github.com/tiny-blocks/environment-variable/security/advisories/new). + +Please do not disclose the vulnerability publicly until it has been addressed. diff --git a/composer.json b/composer.json index 0b9d043..e253db5 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,11 @@ "description": "Provides a type-safe environment variable reader for PHP, with strict integer and boolean conversion.", "license": "MIT", "type": "library", + "keywords": [ + "tiny-blocks", + "environment-variable", + "configuration" + ], "authors": [ { "name": "Gustavo Freze de Araujo Santos", @@ -18,10 +23,11 @@ "php": "^8.5" }, "require-dev": { - "ergebnis/composer-normalize": "^2.51", - "infection/infection": "^0.32", - "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^13.1", + "ergebnis/composer-normalize": "^2.52", + "infection/infection": "^0.34", + "phpstan/phpstan": "^2.2", + "phpunit/phpunit": "^13.2", + "slevomat/coding-standard": "^8.31", "squizlabs/php_codesniffer": "^4.0" }, "minimum-stability": "stable", @@ -38,28 +44,29 @@ }, "config": { "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, "ergebnis/composer-normalize": true, "infection/extension-installer": true }, "sort-packages": true }, "scripts": { - "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", - "phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src", - "phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress", + "configure": [ + "@composer install --optimize-autoloader", + "@composer normalize" + ], + "configure-and-update": [ + "@composer update --optimize-autoloader", + "@composer normalize" + ], "review": [ - "@phpcs", - "@phpstan" + "@php ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php ./src ./tests", + "@php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress" ], - "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", - "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", - "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests", + "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", "tests": [ - "@test", - "@mutation-test" - ], - "tests-no-coverage": [ - "@test-no-coverage" + "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", + "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage" ] } } diff --git a/infection.json.dist b/infection.json.dist index ee435dd..aab8c7e 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -1,9 +1,9 @@ { "logs": { - "text": "report/infection/logs/infection-text.log", - "summary": "report/infection/logs/infection-summary.log" + "text": "reports/infection/logs/infection-text.log", + "summary": "reports/infection/logs/infection-summary.log" }, - "tmpDir": "report/infection/", + "tmpDir": "reports/infection/", "minMsi": 100, "timeout": 30, "source": { @@ -16,8 +16,7 @@ "customPath": "./vendor/bin/phpunit" }, "mutators": { - "@default": true, - "ProtectedVisibility": false + "@default": true }, "minCoveredMsi": 100, "testFramework": "phpunit" diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..96c803e --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,97 @@ + + + Code style for the tiny-blocks library. + + src + tests + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 937f06e..446459d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,6 +1,11 @@ parameters: paths: - src - level: 9 - tmpDir: report/phpstan - reportUnmatchedIgnoredErrors: false + - tests + level: max + tmpDir: reports/phpstan + reportUnmatchedIgnoredErrors: true + ignoreErrors: + - + identifier: missingType.iterableValue + path: tests/* diff --git a/phpunit.xml b/phpunit.xml index 40c80a2..9cc6d13 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,15 @@ + failOnDeprecation="true" + failOnNotice="true" + failOnPhpunitDeprecation="true" + failOnRisky="true" + failOnWarning="true"> @@ -23,15 +25,15 @@ - - - - + + + + - + diff --git a/src/Environment.php b/src/Environment.php index 9665750..733c3b9 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -4,12 +4,18 @@ namespace TinyBlocks\EnvironmentVariable; -use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentValueNotBoolean; -use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentValueNotInteger; -use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentVariableMissing; +use TinyBlocks\EnvironmentVariable\Exceptions\EnvironmentValueNotBoolean; +use TinyBlocks\EnvironmentVariable\Exceptions\EnvironmentValueNotFloat; +use TinyBlocks\EnvironmentVariable\Exceptions\EnvironmentValueNotInteger; +use TinyBlocks\EnvironmentVariable\Exceptions\EnvironmentVariableMissing; /** * Provides methods to handling environment variables. + * + *

Values are resolved from $_ENV, then $_SERVER, then the process + * environment. Names in the HTTP_ namespace are resolved only from sources that an + * HTTP request cannot reach, because CGI-like servers map request headers into + * $_SERVER and into the SAPI environment under that same prefix.

*/ interface Environment { @@ -27,9 +33,17 @@ public static function from(string $name): Environment; * * @param string $name The name of the environment variable. * @param string|null $defaultValueIfNotFound The default value to use if the environment variable is not found. - * @return EnvironmentVariable The environment variable instance, either with the found value or the default. + * @return Environment The environment variable instance, either with the found value or the default. */ - public static function fromOrDefault(string $name, ?string $defaultValueIfNotFound = null): EnvironmentVariable; + public static function fromOrDefault(string $name, ?string $defaultValueIfNotFound = null): Environment; + + /** + * Converts the environment variable value to a float. + * + * @return float The environment variable value as a float. + * @throws EnvironmentValueNotFloat If the value cannot be converted to a float. + */ + public function toFloat(): float; /** * Checks if the environment variable has a value. Values like `false`, `0`, and `-1` are valid and non-empty. @@ -45,14 +59,6 @@ public function hasValue(): bool; */ public function toString(): string; - /** - * Converts the environment variable value to an integer. - * - * @return int The environment variable value as an integer. - * @throws EnvironmentValueNotInteger If the value cannot be converted to an integer. - */ - public function toInteger(): int; - /** * Converts the environment variable value to a boolean. * @@ -60,4 +66,12 @@ public function toInteger(): int; * @throws EnvironmentValueNotBoolean If the value cannot be converted to a boolean. */ public function toBoolean(): bool; + + /** + * Converts the environment variable value to an integer. + * + * @return int The environment variable value as an integer. + * @throws EnvironmentValueNotInteger If the value cannot be converted to an integer. + */ + public function toInteger(): int; } diff --git a/src/EnvironmentVariable.php b/src/EnvironmentVariable.php index 268f383..05e5618 100644 --- a/src/EnvironmentVariable.php +++ b/src/EnvironmentVariable.php @@ -4,61 +4,54 @@ namespace TinyBlocks\EnvironmentVariable; +use TinyBlocks\EnvironmentVariable\Exceptions\EnvironmentVariableMissing; use TinyBlocks\EnvironmentVariable\Internal\EnvironmentSource; -use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentValueNotBoolean; -use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentValueNotInteger; -use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentVariableMissing; +use TinyBlocks\EnvironmentVariable\Internal\EnvironmentValue; final readonly class EnvironmentVariable implements Environment { - private function __construct(private string $value, private string $variable) + private function __construct(private EnvironmentValue $environmentValue) { } public static function from(string $name): EnvironmentVariable { - $environmentVariable = EnvironmentSource::lookup(name: $name); + $value = EnvironmentSource::lookup(name: $name); - return is_null($environmentVariable) + return is_null($value) ? throw new EnvironmentVariableMissing(variable: $name) - : new EnvironmentVariable(value: $environmentVariable, variable: $name); + : new EnvironmentVariable(environmentValue: EnvironmentValue::from(value: $value, variable: $name)); } public static function fromOrDefault(string $name, ?string $defaultValueIfNotFound = null): EnvironmentVariable { - $environmentVariable = EnvironmentSource::lookup(name: $name) ?? $defaultValueIfNotFound ?? ''; + $value = (EnvironmentSource::lookup(name: $name) ?? $defaultValueIfNotFound ?? ''); - return new EnvironmentVariable(value: $environmentVariable, variable: $name); + return new EnvironmentVariable(environmentValue: EnvironmentValue::from(value: $value, variable: $name)); } - public function hasValue(): bool + public function toFloat(): float { - return match (strtolower(trim($this->value))) { - '', 'null' => false, - default => true - }; + return $this->environmentValue->toFloat(); } - public function toString(): string + public function hasValue(): bool { - return $this->value; + return $this->environmentValue->hasValue(); } - public function toInteger(): int + public function toString(): string { - $filteredValue = filter_var($this->value, FILTER_VALIDATE_INT); - - return $filteredValue !== false - ? $filteredValue - : throw new EnvironmentValueNotInteger(variable: $this->variable); + return $this->environmentValue->toString(); } public function toBoolean(): bool { - $filteredValue = filter_var($this->value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + return $this->environmentValue->toBoolean(); + } - return $filteredValue !== null - ? $filteredValue - : throw new EnvironmentValueNotBoolean(variable: $this->variable); + public function toInteger(): int + { + return $this->environmentValue->toInteger(); } } diff --git a/src/Internal/Exceptions/EnvironmentValueNotBoolean.php b/src/Exceptions/EnvironmentValueNotBoolean.php similarity index 55% rename from src/Internal/Exceptions/EnvironmentValueNotBoolean.php rename to src/Exceptions/EnvironmentValueNotBoolean.php index 74f5d81..8aa47db 100644 --- a/src/Internal/Exceptions/EnvironmentValueNotBoolean.php +++ b/src/Exceptions/EnvironmentValueNotBoolean.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace TinyBlocks\EnvironmentVariable\Internal\Exceptions; +namespace TinyBlocks\EnvironmentVariable\Exceptions; use InvalidArgumentException; final class EnvironmentValueNotBoolean extends InvalidArgumentException { - public function __construct(private readonly string $variable) + public function __construct(string $variable) { $template = 'The value for environment variable <%s> is invalid for conversion to .'; - parent::__construct(message: sprintf($template, $this->variable)); + parent::__construct(message: sprintf($template, $variable)); } } diff --git a/src/Exceptions/EnvironmentValueNotFloat.php b/src/Exceptions/EnvironmentValueNotFloat.php new file mode 100644 index 0000000..eb7ce54 --- /dev/null +++ b/src/Exceptions/EnvironmentValueNotFloat.php @@ -0,0 +1,17 @@ + is invalid for conversion to .'; + + parent::__construct(message: sprintf($template, $variable)); + } +} diff --git a/src/Internal/Exceptions/EnvironmentValueNotInteger.php b/src/Exceptions/EnvironmentValueNotInteger.php similarity index 55% rename from src/Internal/Exceptions/EnvironmentValueNotInteger.php rename to src/Exceptions/EnvironmentValueNotInteger.php index fabce0c..ff3199a 100644 --- a/src/Internal/Exceptions/EnvironmentValueNotInteger.php +++ b/src/Exceptions/EnvironmentValueNotInteger.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace TinyBlocks\EnvironmentVariable\Internal\Exceptions; +namespace TinyBlocks\EnvironmentVariable\Exceptions; use InvalidArgumentException; final class EnvironmentValueNotInteger extends InvalidArgumentException { - public function __construct(private readonly string $variable) + public function __construct(string $variable) { $template = 'The value for environment variable <%s> is invalid for conversion to .'; - parent::__construct(message: sprintf($template, $this->variable)); + parent::__construct(message: sprintf($template, $variable)); } } diff --git a/src/Internal/Exceptions/EnvironmentVariableMissing.php b/src/Exceptions/EnvironmentVariableMissing.php similarity index 51% rename from src/Internal/Exceptions/EnvironmentVariableMissing.php rename to src/Exceptions/EnvironmentVariableMissing.php index f7e76d3..b415f46 100644 --- a/src/Internal/Exceptions/EnvironmentVariableMissing.php +++ b/src/Exceptions/EnvironmentVariableMissing.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace TinyBlocks\EnvironmentVariable\Internal\Exceptions; +namespace TinyBlocks\EnvironmentVariable\Exceptions; use InvalidArgumentException; final class EnvironmentVariableMissing extends InvalidArgumentException { - public function __construct(private readonly string $variable) + public function __construct(string $variable) { $template = 'Environment variable <%s> is missing.'; - parent::__construct(message: sprintf($template, $this->variable)); + parent::__construct(message: sprintf($template, $variable)); } } diff --git a/src/Internal/EnvironmentSource.php b/src/Internal/EnvironmentSource.php index 499650b..86283dd 100644 --- a/src/Internal/EnvironmentSource.php +++ b/src/Internal/EnvironmentSource.php @@ -4,20 +4,38 @@ namespace TinyBlocks\EnvironmentVariable\Internal; -final readonly class EnvironmentSource +final class EnvironmentSource { + private const string REQUEST_HEADER_PREFIX = 'HTTP_'; + + private function __construct() + { + } + public static function lookup(string $name): ?string { - if (array_key_exists($name, $_ENV) && is_scalar($_ENV[$name])) { - return (string)$_ENV[$name]; - } + $requestControlled = str_starts_with($name, self::REQUEST_HEADER_PREFIX); - if (array_key_exists($name, $_SERVER) && is_scalar($_SERVER[$name])) { - return (string)$_SERVER[$name]; - } + $value = (self::fromScalar(value: ($_ENV[$name] ?? null)) + ?? self::fromRequestScope(value: ($_SERVER[$name] ?? null), requestControlled: $requestControlled)); - $value = getenv($name); + return ($value ?? self::fromProcess(name: $name, localOnly: $requestControlled)); + } + + private static function fromScalar(mixed $value): ?string + { + return is_scalar($value) ? (string)$value : null; + } + + private static function fromProcess(string $name, bool $localOnly): ?string + { + $value = getenv($name, $localOnly); return $value === false ? null : $value; } + + private static function fromRequestScope(mixed $value, bool $requestControlled): ?string + { + return $requestControlled ? null : self::fromScalar(value: $value); + } } diff --git a/src/Internal/EnvironmentValue.php b/src/Internal/EnvironmentValue.php new file mode 100644 index 0000000..0e5516d --- /dev/null +++ b/src/Internal/EnvironmentValue.php @@ -0,0 +1,59 @@ +value, FILTER_VALIDATE_FLOAT); + + return $filteredValue !== false + ? $filteredValue + : throw new EnvironmentValueNotFloat(variable: $this->variable); + } + + public function hasValue(): bool + { + return match (strtolower(trim($this->value))) { + '', 'null' => false, + default => true + }; + } + + public function toString(): string + { + return $this->value; + } + + public function toBoolean(): bool + { + $filteredValue = filter_var($this->value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + return ($filteredValue ?? throw new EnvironmentValueNotBoolean(variable: $this->variable)); + } + + public function toInteger(): int + { + $filteredValue = filter_var($this->value, FILTER_VALIDATE_INT); + + return $filteredValue !== false + ? $filteredValue + : throw new EnvironmentValueNotInteger(variable: $this->variable); + } +} diff --git a/tests/EnvironmentSourceTest.php b/tests/EnvironmentSourceTest.php new file mode 100644 index 0000000..19899e3 --- /dev/null +++ b/tests/EnvironmentSourceTest.php @@ -0,0 +1,25 @@ +invoke(new ReflectionClass(EnvironmentSource::class)->newInstanceWithoutConstructor()); + + /** @Then it stays private, so the source exposes only static lookups */ + self::assertTrue($constructor->isPrivate()); + } +} diff --git a/tests/EnvironmentVariableTest.php b/tests/EnvironmentVariableTest.php index a3e351d..f995658 100644 --- a/tests/EnvironmentVariableTest.php +++ b/tests/EnvironmentVariableTest.php @@ -7,9 +7,10 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use TinyBlocks\EnvironmentVariable\EnvironmentVariable; -use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentValueNotBoolean; -use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentValueNotInteger; -use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentVariableMissing; +use TinyBlocks\EnvironmentVariable\Exceptions\EnvironmentValueNotBoolean; +use TinyBlocks\EnvironmentVariable\Exceptions\EnvironmentValueNotFloat; +use TinyBlocks\EnvironmentVariable\Exceptions\EnvironmentValueNotInteger; +use TinyBlocks\EnvironmentVariable\Exceptions\EnvironmentVariableMissing; final class EnvironmentVariableTest extends TestCase { @@ -17,9 +18,11 @@ final class EnvironmentVariableTest extends TestCase 'MY_VAR', 'VALID_INT', 'INVALID_INT', + 'VALID_FLOAT', 'INVALID_BOOL', 'NON_EXISTENT', 'NULL_VALUE', + 'HTTP_PROXY', 'EMPTY_STRING', 'VALID_STRING', 'NEGATIVE_INT', @@ -29,12 +32,18 @@ final class EnvironmentVariableTest extends TestCase 'INTEGER_ZERO', 'NUMERIC_FALSE', 'BOOLEAN_FALSE', + 'INVALID_FLOAT', 'NON_SCALAR_ENV', 'NUMERIC_STRING', + 'NEGATIVE_FLOAT', + 'ENV_AND_PROCESS', 'NON_EXISTENT_VAR', 'INTEGER_POSITIVE', 'INTEGER_NEGATIVE', + 'INTEGER_AS_FLOAT', + 'SCIENTIFIC_FLOAT', 'NON_SCALAR_SERVER', + 'BOTH_SUPERGLOBALS', 'STRING_WITH_SPACES', 'FROM_ENV_SUPERGLOBAL', 'NON_EXISTENT_MY_VAR', @@ -49,51 +58,48 @@ protected function tearDown(): void } } - #[DataProvider('stringConversionDataProvider')] - public function testToStringWhenValuePresentThenReturnsExpectedString( - mixed $value, - string $variable, - string $expected - ): void { - /** @Given the environment variable is set with the given raw value */ + #[DataProvider('hasValueDataProvider')] + public function testHasValueWhenValueIsMeaningfulThenReturnsTrue(string $value, string $variable): void + { + /** @Given the environment variable is set with a meaningful value */ putenv(sprintf('%s=%s', $variable, $value)); - /** @When converting the environment variable to string */ - $actual = EnvironmentVariable::from(name: $variable)->toString(); + /** @When checking if the environment variable has a value */ + $actual = EnvironmentVariable::from(name: $variable)->hasValue(); - /** @Then the returned string matches the expected representation */ - self::assertSame($expected, $actual); + /** @Then the check reports the presence of a value */ + self::assertTrue($actual); } - #[DataProvider('integerConversionDataProvider')] - public function testToIntegerWhenValueIsNumericThenReturnsExpectedInteger( + #[DataProvider('floatConversionDataProvider')] + public function testToFloatWhenValueIsNumericThenReturnsExpectedFloat( string $value, string $variable, - int $expected + float $expected ): void { /** @Given the environment variable is set with a numeric string */ putenv(sprintf('%s=%s', $variable, $value)); - /** @When converting the environment variable to integer */ - $actual = EnvironmentVariable::from(name: $variable)->toInteger(); + /** @When converting the environment variable to float */ + $actual = EnvironmentVariable::from(name: $variable)->toFloat(); - /** @Then the returned integer matches the expected value */ + /** @Then the returned float matches the expected value */ self::assertSame($expected, $actual); } - #[DataProvider('booleanConversionDataProvider')] - public function testToBooleanWhenValueIsBooleanLikeThenReturnsExpectedBoolean( - string $value, + #[DataProvider('stringConversionDataProvider')] + public function testToStringWhenValuePresentThenReturnsExpectedString( + string|bool $value, string $variable, - bool $expected + string $expected ): void { - /** @Given the environment variable is set with a boolean-like value */ + /** @Given the environment variable is set with the given raw value */ putenv(sprintf('%s=%s', $variable, $value)); - /** @When converting the environment variable to boolean */ - $actual = EnvironmentVariable::from(name: $variable)->toBoolean(); + /** @When converting the environment variable to string */ + $actual = EnvironmentVariable::from(name: $variable)->toString(); - /** @Then the returned boolean matches the expected value */ + /** @Then the returned string matches the expected representation */ self::assertSame($expected, $actual); } @@ -109,79 +115,84 @@ public function testFromOrDefaultWhenVariableMissingThenReturnsDefault(): void self::assertSame(0, $actual->toInteger()); } - public function testFromOrDefaultWhenVariableExistsThenReturnsExistingValue(): void + public function testFromWhenNonScalarInEnvSuperglobalThenThrowsMissing(): void { - /** @Given the environment variable exists with an existing value */ - putenv(sprintf('%s=%s', 'MY_VAR', 'existing_value')); + /** @Given a non-scalar entry in $_ENV */ + $_ENV['NON_SCALAR_ENV'] = ['nested' => 'value']; - /** @When requesting the variable with a default value */ - $actual = EnvironmentVariable::fromOrDefault(name: 'MY_VAR', defaultValueIfNotFound: 'default_value'); + /** @Then a missing environment variable exception is expected */ + $this->expectException(EnvironmentVariableMissing::class); - /** @Then the returned instance exposes the existing value */ - self::assertSame('existing_value', $actual->toString()); + /** @When reading the environment variable */ + EnvironmentVariable::from(name: 'NON_SCALAR_ENV'); } - public function testFromOrDefaultWhenVariableMissingAndNoDefaultThenToStringIsEmpty(): void + #[DataProvider('hasNoValueDataProvider')] + public function testHasValueWhenValueIsAbsentOrNullLikeThenReturnsFalse(?string $value, string $variable): void { - /** @Given the environment variable does not exist */ - $variable = 'NON_EXISTENT_VAR'; + /** @Given the environment variable is set with a null-like value */ + putenv(sprintf('%s=%s', $variable, $value)); - /** @When requesting the variable without a default value */ - $actual = EnvironmentVariable::fromOrDefault(name: $variable); + /** @When checking if the environment variable has a value */ + $actual = EnvironmentVariable::from(name: $variable)->hasValue(); - /** @Then the returned instance exposes an empty string */ - self::assertSame('', $actual->toString()); + /** @Then the check reports the absence of a value */ + self::assertFalse($actual); } - public function testFromOrDefaultWhenVariableMissingAndNoDefaultThenHasValueIsFalse(): void + public function testFromWhenNonScalarInServerSuperglobalThenThrowsMissing(): void { - /** @Given the environment variable does not exist */ - $variable = 'NON_EXISTENT_VAR'; + /** @Given a non-scalar entry in $_SERVER */ + $_SERVER['NON_SCALAR_SERVER'] = ['nested' => 'value']; - /** @When requesting the variable without a default value */ - $actual = EnvironmentVariable::fromOrDefault(name: $variable); + /** @Then a missing environment variable exception is expected */ + $this->expectException(EnvironmentVariableMissing::class); - /** @Then the returned instance reports no value */ - self::assertFalse($actual->hasValue()); + /** @When reading the environment variable */ + EnvironmentVariable::from(name: 'NON_SCALAR_SERVER'); } - #[DataProvider('hasValueDataProvider')] - public function testHasValueWhenValueIsMeaningfulThenReturnsTrue(string $value, string $variable): void - { - /** @Given the environment variable is set with a meaningful value */ + #[DataProvider('integerConversionDataProvider')] + public function testToIntegerWhenValueIsNumericThenReturnsExpectedInteger( + string $value, + string $variable, + int $expected + ): void { + /** @Given the environment variable is set with a numeric string */ putenv(sprintf('%s=%s', $variable, $value)); - /** @When checking if the environment variable has a value */ - $actual = EnvironmentVariable::from(name: $variable)->hasValue(); + /** @When converting the environment variable to integer */ + $actual = EnvironmentVariable::from(name: $variable)->toInteger(); - /** @Then the check reports the presence of a value */ - self::assertTrue($actual); + /** @Then the returned integer matches the expected value */ + self::assertSame($expected, $actual); } - #[DataProvider('hasNoValueDataProvider')] - public function testHasValueWhenValueIsAbsentOrNullLikeThenReturnsFalse(?string $value, string $variable): void + public function testFromOrDefaultWhenVariableExistsThenReturnsExistingValue(): void { - /** @Given the environment variable is set with a null-like value */ - putenv(sprintf('%s=%s', $variable, $value)); + /** @Given the environment variable exists with an existing value */ + putenv(sprintf('%s=%s', 'MY_VAR', 'existing_value')); - /** @When checking if the environment variable has a value */ - $actual = EnvironmentVariable::from(name: $variable)->hasValue(); + /** @When requesting the variable with a default value */ + $actual = EnvironmentVariable::fromOrDefault(name: 'MY_VAR', defaultValueIfNotFound: 'default_value'); - /** @Then the check reports the absence of a value */ - self::assertFalse($actual); + /** @Then the returned instance exposes the existing value */ + self::assertSame('existing_value', $actual->toString()); } - public function testFromWhenVariableIsMissingThenThrowsEnvironmentVariableMissing(): void + public function testFromWhenPresentInBothSuperglobalsThenEnvSuperglobalWins(): void { - /** @Given the environment variable does not exist */ - $variable = 'NON_EXISTENT'; + /** @Given a value available in $_ENV */ + $_ENV['BOTH_SUPERGLOBALS'] = 'from-env'; - /** @Then a missing environment variable exception is expected */ - $this->expectException(EnvironmentVariableMissing::class); - $this->expectExceptionMessage('Environment variable is missing.'); + /** @And a different value available in $_SERVER under the same name */ + $_SERVER['BOTH_SUPERGLOBALS'] = 'from-server'; - /** @When requesting the missing environment variable */ - EnvironmentVariable::from(name: $variable); + /** @When reading the environment variable */ + $actual = EnvironmentVariable::from(name: 'BOTH_SUPERGLOBALS')->toString(); + + /** @Then the value from $_ENV takes precedence */ + self::assertSame('from-env', $actual); } public function testFromWhenScalarPresentInEnvSuperglobalThenValueIsCoerced(): void @@ -196,6 +207,22 @@ public function testFromWhenScalarPresentInEnvSuperglobalThenValueIsCoerced(): v self::assertSame('42', $actual); } + #[DataProvider('booleanConversionDataProvider')] + public function testToBooleanWhenValueIsBooleanLikeThenReturnsExpectedBoolean( + string $value, + string $variable, + bool $expected + ): void { + /** @Given the environment variable is set with a boolean-like value */ + putenv(sprintf('%s=%s', $variable, $value)); + + /** @When converting the environment variable to boolean */ + $actual = EnvironmentVariable::from(name: $variable)->toBoolean(); + + /** @Then the returned boolean matches the expected value */ + self::assertSame($expected, $actual); + } + public function testFromWhenScalarPresentInServerSuperglobalThenValueIsCoerced(): void { /** @Given a non-string scalar available only in $_SERVER */ @@ -208,28 +235,98 @@ public function testFromWhenScalarPresentInServerSuperglobalThenValueIsCoerced() self::assertSame('7', $actual); } - public function testFromWhenNonScalarInEnvSuperglobalThenThrowsMissing(): void + public function testFromWhenRequestScopedNameIsSetInProcessThenProcessValueWins(): void { - /** @Given a non-scalar entry in $_ENV */ - $_ENV['NON_SCALAR_ENV'] = ['nested' => 'value']; + /** @Given a request header mapped into $_SERVER under the HTTP_ namespace */ + $_SERVER['HTTP_PROXY'] = 'http://attacker.example.com'; + + /** @And the same name present in the process environment */ + putenv(sprintf('%s=%s', 'HTTP_PROXY', 'http://proxy.internal')); + + /** @When reading the environment variable */ + $actual = EnvironmentVariable::from(name: 'HTTP_PROXY')->toString(); + + /** @Then the process value is returned and the request header is ignored */ + self::assertSame('http://proxy.internal', $actual); + } + + public function testFromWhenPresentInEnvSuperglobalAndProcessThenSuperglobalWins(): void + { + /** @Given a value available in $_ENV */ + $_ENV['ENV_AND_PROCESS'] = 'from-env'; + + /** @And a different value present in the process environment */ + putenv(sprintf('%s=%s', 'ENV_AND_PROCESS', 'from-process')); + + /** @When reading the environment variable */ + $actual = EnvironmentVariable::from(name: 'ENV_AND_PROCESS')->toString(); + + /** @Then the value from $_ENV takes precedence */ + self::assertSame('from-env', $actual); + } + + public function testFromWhenVariableIsMissingThenThrowsEnvironmentVariableMissing(): void + { + /** @Given the environment variable does not exist */ + $variable = 'NON_EXISTENT'; /** @Then a missing environment variable exception is expected */ $this->expectException(EnvironmentVariableMissing::class); + $this->expectExceptionMessage('Environment variable is missing.'); - /** @When reading the environment variable */ - EnvironmentVariable::from(name: 'NON_SCALAR_ENV'); + /** @When requesting the missing environment variable */ + EnvironmentVariable::from(name: $variable); } - public function testFromWhenNonScalarInServerSuperglobalThenThrowsMissing(): void + public function testToFloatWhenValueIsNotNumericThenThrowsEnvironmentValueNotFloat(): void { - /** @Given a non-scalar entry in $_SERVER */ - $_SERVER['NON_SCALAR_SERVER'] = ['nested' => 'value']; + /** @Given the environment variable holds a non-numeric value */ + putenv(sprintf('%s=%s', 'INVALID_FLOAT', 'invalid-value')); + + /** @Then an invalid float conversion exception is expected */ + $this->expectException(EnvironmentValueNotFloat::class); + $this->expectExceptionMessage( + 'The value for environment variable is invalid for conversion to .' + ); + + /** @When converting the environment variable to float */ + EnvironmentVariable::from(name: 'INVALID_FLOAT')->toFloat(); + } + + public function testFromOrDefaultWhenVariableMissingAndNoDefaultThenHasValueIsFalse(): void + { + /** @Given the environment variable does not exist */ + $variable = 'NON_EXISTENT_VAR'; + + /** @When requesting the variable without a default value */ + $actual = EnvironmentVariable::fromOrDefault(name: $variable); + + /** @Then the returned instance reports no value */ + self::assertFalse($actual->hasValue()); + } + + public function testFromOrDefaultWhenVariableMissingAndNoDefaultThenToStringIsEmpty(): void + { + /** @Given the environment variable does not exist */ + $variable = 'NON_EXISTENT_VAR'; + + /** @When requesting the variable without a default value */ + $actual = EnvironmentVariable::fromOrDefault(name: $variable); + + /** @Then the returned instance exposes an empty string */ + self::assertSame('', $actual->toString()); + } + + public function testFromWhenRequestScopedNameOnlyInServerSuperglobalThenThrowsMissing(): void + { + /** @Given a request header mapped into $_SERVER under the HTTP_ namespace */ + $_SERVER['HTTP_PROXY'] = 'http://attacker.example.com'; /** @Then a missing environment variable exception is expected */ $this->expectException(EnvironmentVariableMissing::class); /** @When reading the environment variable */ - EnvironmentVariable::from(name: 'NON_SCALAR_SERVER'); + EnvironmentVariable::from(name: 'HTTP_PROXY'); } public function testToIntegerWhenValueIsNotNumericThenThrowsEnvironmentValueNotInteger(): void @@ -262,6 +359,84 @@ public function testToBooleanWhenValueIsNotBooleanLikeThenThrowsEnvironmentValue EnvironmentVariable::from(name: 'INVALID_BOOL')->toBoolean(); } + public static function hasValueDataProvider(): array + { + return [ + 'String value' => [ + 'value' => 'Hello, World!', + 'variable' => 'STRING_VALUE' + ], + 'Integer value 0' => [ + 'value' => '0', + 'variable' => 'INTEGER_ZERO' + ], + 'Boolean value true' => [ + 'value' => 'true', + 'variable' => 'BOOLEAN_TRUE' + ], + 'Boolean value false' => [ + 'value' => 'false', + 'variable' => 'BOOLEAN_FALSE' + ], + 'Integer value positive' => [ + 'value' => '123', + 'variable' => 'INTEGER_POSITIVE' + ], + 'Integer value negative' => [ + 'value' => '-1', + 'variable' => 'INTEGER_NEGATIVE' + ] + ]; + } + + public static function hasNoValueDataProvider(): array + { + return [ + 'Null value' => [ + 'value' => null, + 'variable' => 'NULL_VALUE' + ], + 'Empty string' => [ + 'value' => '', + 'variable' => 'EMPTY_STRING' + ], + 'String null value' => [ + 'value' => 'NULL', + 'variable' => 'NULL_VALUE' + ], + 'String with only spaces' => [ + 'value' => ' ', + 'variable' => 'STRING_WITH_SPACES' + ] + ]; + } + + public static function floatConversionDataProvider(): array + { + return [ + 'Float value' => [ + 'value' => '1.5', + 'variable' => 'VALID_FLOAT', + 'expected' => 1.5 + ], + 'Integer value' => [ + 'value' => '2', + 'variable' => 'INTEGER_AS_FLOAT', + 'expected' => 2.0 + ], + 'Negative float' => [ + 'value' => '-0.5', + 'variable' => 'NEGATIVE_FLOAT', + 'expected' => -0.5 + ], + 'Scientific notation' => [ + 'value' => '1e3', + 'variable' => 'SCIENTIFIC_FLOAT', + 'expected' => 1000.0 + ] + ]; + } + public static function stringConversionDataProvider(): array { return [ @@ -288,27 +463,6 @@ public static function stringConversionDataProvider(): array ]; } - public static function integerConversionDataProvider(): array - { - return [ - 'Integer value' => [ - 'value' => '123', - 'variable' => 'VALID_INT', - 'expected' => 123 - ], - 'Numeric string' => [ - 'value' => '42', - 'variable' => 'NUMERIC_STRING', - 'expected' => 42 - ], - 'Negative integer' => [ - 'value' => '-7', - 'variable' => 'NEGATIVE_INT', - 'expected' => -7 - ] - ]; - } - public static function booleanConversionDataProvider(): array { return [ @@ -335,54 +489,23 @@ public static function booleanConversionDataProvider(): array ]; } - public static function hasValueDataProvider(): array + public static function integerConversionDataProvider(): array { return [ - 'String value' => [ - 'value' => 'Hello, World!', - 'variable' => 'STRING_VALUE' - ], - 'Integer value 0' => [ - 'value' => '0', - 'variable' => 'INTEGER_ZERO' - ], - 'Boolean value true' => [ - 'value' => 'true', - 'variable' => 'BOOLEAN_TRUE' - ], - 'Boolean value false' => [ - 'value' => 'false', - 'variable' => 'BOOLEAN_FALSE' - ], - 'Integer value positive' => [ + 'Integer value' => [ 'value' => '123', - 'variable' => 'INTEGER_POSITIVE' - ], - 'Integer value negative' => [ - 'value' => '-1', - 'variable' => 'INTEGER_NEGATIVE' - ] - ]; - } - - public static function hasNoValueDataProvider(): array - { - return [ - 'Null value' => [ - 'value' => null, - 'variable' => 'NULL_VALUE' - ], - 'Empty string' => [ - 'value' => '', - 'variable' => 'EMPTY_STRING' + 'variable' => 'VALID_INT', + 'expected' => 123 ], - 'String null value' => [ - 'value' => 'NULL', - 'variable' => 'NULL_VALUE' + 'Numeric string' => [ + 'value' => '42', + 'variable' => 'NUMERIC_STRING', + 'expected' => 42 ], - 'String with only spaces' => [ - 'value' => ' ', - 'variable' => 'STRING_WITH_SPACES' + 'Negative integer' => [ + 'value' => '-7', + 'variable' => 'NEGATIVE_INT', + 'expected' => -7 ] ]; }