diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 11885b0..a561aa6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,32 +1,16 @@ -# 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). +# CLAUDE.md + +This is a PHP library in the tiny-blocks ecosystem. Detailed rules live in `.claude/rules/`. +Each file is scoped via its `paths` frontmatter. Read the relevant file before producing or +editing content under its scope. + +## Rule files + +- `php-library-architecture.md` — folder structure, public API boundary, `Internal/` semantics. +- `php-library-code-style.md` — semantic code rules for `.php` files in `src/` and `tests/`. +- `php-library-commits.md` — Conventional Commits format. Applied only when generating commit messages. +- `php-library-documentation.md` — README and Markdown documentation standards. +- `php-library-github-workflows.md` — CI workflow structure and action pinning. +- `php-library-modeling.md` — nomenclature, value objects, exceptions, enums, complexity. +- `php-library-testing.md` — BDD Given/When/Then, PHPUnit conventions, coverage discipline. +- `php-library-tooling.md` — canonical config files (`composer.json`, `phpcs.xml`, etc). 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-architecture.md b/.claude/rules/php-library-architecture.md new file mode 100644 index 0000000..7e4be10 --- /dev/null +++ b/.claude/rules/php-library-architecture.md @@ -0,0 +1,145 @@ +--- +description: Folder structure, public API boundary, and Internal/ semantics for PHP libraries. +paths: + - "src/**/*.php" +--- + +# Architecture + +Covers the physical layout of the library. Folder structure, the boundary between public API and +implementation detail, and where each type of class lives. Semantic rules (value objects, +exceptions, enums, complexity, nomenclature) live in `php-library-modeling.md`. Code style lives +in `php-library-code-style.md`. + +## Pre-output checklist + +Verify every item before producing or relocating any file. If any item fails, revise before +outputting. + +1. None of the following folder names exist in `src/`: `Models/`, `Entities/`, `ValueObjects/`, + `Enums/`, `Domain/`. They carry no semantic content and conflate technical role with domain + meaning. +2. The `src/` root contains only interfaces, extension points, public enums, thin orchestration + classes, and primary implementations or façades. Substantial logic (algorithms, state machines, + I/O) lives in `src/Internal/`, never at the root. +3. `src/Internal/` is implementation detail and not part of the public API. Breaking changes + inside `src/Internal/` are not semver-breaking. +4. Consumers must not reference, extend, or depend on any type inside `src/Internal/`. The + namespace itself is the boundary. +5. Public exception classes live in `src/Exceptions/`. +6. Internal exception classes live in `src/Internal/Exceptions/`. +7. Public enums live at the `src/` root or inside a public `/` folder. Enums used + only by internals live in `src/Internal/`. +8. Public interfaces live at the `src/` root or inside a public `/` folder. +9. A `/` folder at the `src/` root groups related public types under a shared + concept. Each group has its own namespace and is part of the public API. +10. `/` is optional. Use it only when the library exposes several coherent groups of + types (for example, aggregates and events) rather than a flat set of types around a single + concept. +11. Test fixtures representing domain concepts live in `tests/Models/`. Test doubles for system + boundaries live at the root of `tests/Unit/` or `tests/Integration/`. No dedicated `Mocks/` + or `Doubles/` subdirectory exists. `tests/Drivers//` is permitted when the library + exposes a port exercised against multiple third-party implementations (PSR adapters, + framework integrations). Each `/` subdir holds tests against one specific + implementation. +12. The `tests/Integration/` folder exists only when the library interacts with external + infrastructure (filesystem, database, network). Otherwise, the folder is absent. + +## Folder structure + +Canonical layout for a PHP library in the tiny-blocks ecosystem. + +``` +src/ +├── .php # public contract at root +├── .php # main implementation or extension point at root +├── .php # public enum at root +├── / # public folder grouping related public types under a shared concept +│ ├── .php +│ └── ... +├── Internal/ # implementation details, not part of the public API +│ ├── .php +│ └── Exceptions/ # internal exception classes +└── Exceptions/ # public exception classes + +tests/ +├── Models/ # domain fixtures reused across tests +├── Unit/ # unit tests targeting the public API +│ ├── .php # test doubles at root of Unit/ +│ └── .php +└── Integration/ # only present when the library interacts with infrastructure + └── .php # test doubles at root of Integration/ when needed +``` + +Never use `Models/`, `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. They +carry no semantic content and describe technical role instead of domain meaning. + +## Public API boundary + +The `src/` root is the contract. Everything at the root, plus everything inside public +`/` folders and the public `Exceptions/` folder, is what consumers depend on. Changes +to these types follow semver rules. + +`src/Internal/` is implementation detail. The namespace itself signals the boundary. Consumers +must not depend on any type inside `src/Internal/`. Breaking changes inside `src/Internal/` are +not semver-breaking for the library. + +### What lives at the public boundary + +- Interfaces that define contracts for consumers. +- Extension points designed to be subclassed or composed by consumers. +- Public enums and value objects consumers manipulate directly. +- Thin orchestration classes that wire collaborators together without containing substantial logic. +- Public exception classes consumers may catch. + +### What lives in `src/Internal/` + +- Algorithms, state machines, and complex transformations. +- Adapters for I/O (filesystem, network, database). +- Collaborators that exist purely to break a public class into testable units. +- Implementation details that may change between minor or patch releases. +- Internal exception classes raised by collaborators. + +## Reference examples + +### Small library with flat root + +``` +src/ +├── Timezone.php # public value object +├── Timezones.php # public collection +├── Clock.php # public interface +└── Internal/ + ├── SystemClock.php # default Clock implementation + └── Exceptions/ + └── InvalidTimezone.php +``` + +Everything lives at the root or inside `Internal/`. No `/` folders. Suitable when +the library exposes a small, cohesive set of types around a single concept. + +### Library with public concept groups + +``` +src/ +├── ValueObject.php # public extension point at root +├── Aggregate/ # public namespace grouping aggregate types +│ ├── AggregateRoot.php +│ ├── EventualAggregateRoot.php +│ └── ModelVersion.php +├── Event/ # public namespace grouping event types +│ ├── EventRecord.php +│ ├── EventRecords.php +│ └── SequenceNumber.php +├── Internal/ +│ ├── DefaultModelVersionResolver.php +│ └── Exceptions/ +│ └── InvalidSequenceNumber.php +└── Exceptions/ + └── EventRecordingFailure.php +``` + +`Aggregate/` and `Event/` are public folders at the root, each grouping a coherent set of public +types under one shared concept. Consumers import directly, for example +`TinyBlocks\\Aggregate\AggregateRoot`. Suitable when the library exposes several distinct +concept areas, each with its own set of related types. diff --git a/.claude/rules/php-library-code-style.md b/.claude/rules/php-library-code-style.md index 7ec196e..8485df7 100644 --- a/.claude/rules/php-library-code-style.md +++ b/.claude/rules/php-library-code-style.md @@ -1,5 +1,5 @@ --- -description: Pre-output checklist, naming, typing, complexity, and PHPDoc rules for all PHP files in libraries. +description: Semantic code rules for all PHP files in libraries. paths: - "src/**/*.php" - "tests/**/*.php" @@ -7,136 +7,447 @@ paths: # 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. +Semantic rules for all PHP files in libraries. Formatting rules covered by `PSR-12` are enforced +by `phpcs.xml`. Two formatting rules outside `PSR-12` (no vertical alignment, no trailing comma in +multi-line lists) are documented at the end of this file under "Formatting overrides". Complexity +rules live in `php-library-modeling.md`. Folder structure, public API boundary, and the semantics +of `Internal/` live in `php-library-architecture.md`. ## 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). +2. All parameters, return types, and properties have explicit types. +3. Constructor property promotion is used. +4. Named arguments are used at call sites for own code, tests, and third-party library methods + (for example, tiny-blocks). Never use named arguments on: + - Native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`, + `iterator_to_array`, `sprintf`, `implode`, and similar). + - Native PHP enum methods (`from`, `tryFrom`, `cases`). + - PHPUnit assertions and expectations (`assertEquals`, `assertSame`, `assertTrue`, + `expectException`, and similar). + - Interfaces from PHP-FIG PSR standards (PSR-7 `withHeader`, PSR-18 `sendRequest`, etc.). + The PSR contract does not include parameter names. Implementations may rename parameters. + - Calls that include variadic spread (`...$args`). PHP rejects positional argument unpacking + after named arguments. When the caller passes through a `...$variadic`, all arguments are + positional. New own-code APIs should prefer a typed collection parameter over a variadic + so named-argument call sites remain possible. + + Native PHP **class constructors** (`parent::__construct` calls to `\Exception`, + `\RuntimeException`, `\InvalidArgumentException`, `\LogicException`, and similar) are not + in the list above. They accept named arguments, and rule 8 requires using them whenever + the positional call would pass an argument whose value equals the parameter's default. + Example: `parent::__construct(message: sprintf(...), previous: $previous)` instead of + `parent::__construct(sprintf(...), 0, $previous)`. The exclusion above covers native + functions and enum methods, not native class instantiation. +5. Classes follow the rules in "Inheritance and constructors". `final readonly` is the default, + with documented exceptions for extension points and for parents that are not `readonly`. +6. 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. This + ordering may be overridden only when the alternative carries explicit documentation value: + grouping by domain class with section markers (HTTP status codes by 1xx/2xx/3xx/etc), + mirroring the order of an implemented interface, or similar evident structure. The override + must be obvious at first reading. + + **At call sites** (chained method calls in production code, tests, or documentation + examples), consecutive method invocations on the same receiver are ordered by the **visible + width** of each call expression ascending. The body is not visible at the call site, so the + visible width is the practical proxy for body size. Boolean toggles such as `->secure()` and + `->httpOnly()` come before parameterized `with*` builders for the same reason. When two + calls have equal width, order them alphabetically by method name. + + **Terminal methods that change the receiver type** stay at the end of the chain regardless + of width. A `build()` that returns the built value, a `commit()` that finalizes a unit of + work, a `send()` that flushes a request, are terminal: the chain ends with them. The + ordering rule applies only to consecutive calls on the same receiver type; calls that + transition to a different type are not reorderable. The same applies in reverse to the + factory or accessor that starts the chain (`Cookie::create(...)`, `$repository`) — it stays + at its position. +7. Constructor parameters are ordered by parameter name length ascending (count the name only, + without `$` or type), except when parameters have an implicit semantic order (for example, + `$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 order: `$id` (2), `$value` (5), `$status` (6), `$precision` (9). +8. Never pass an argument whose value equals the parameter's default. Omit the argument entirely. + Example with `toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE)`. The call + `$collection->toArray(keyPreservation: KeyPreservation::PRESERVE)` becomes + `$collection->toArray()`. Only pass the argument when the value differs from the default. +9. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead. +10. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of + `$acc`. +11. No generic identifiers exist. Use domain-specific names instead. Examples are `$data` to + `$payload`, `$value` to `$totalAmount`, `$item` to `$element`, `$info` to `$currencyDetails`, + `$result` to `$conversionOutcome`. +12. No raw arrays exist where a typed collection or value object is available. When data is + `Collectible`, use the `tiny-blocks/collection` fluent API (`Collection`, `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" for the full rule and example. +13. No private methods exist except for private constructors in factory patterns, methods inside + `src/Internal/` (implementation detail by definition, where the namespace is the abstraction + boundary), and `setUp` or `tearDown` overrides in PHPUnit test classes. Outside these cases, + inline trivial logic at the call site or extract it to a collaborator or value object. 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, - ); - ``` +16. No inline comments exist in `src/` or `tests/`, except `# TODO: ` when implementation + is unknown, uncertain, or intentionally deferred. Code is the documentation. Block comments + (`/* */`) never appear outside docblocks (`/** */`). The `#` style for inline PHP comments + applies only to code examples inside Markdown files (see `php-library-documentation.md`). +17. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports. +18. Never create public methods, constants, or classes in `src/` solely to serve tests. If + production code does not need it, it does not exist. +19. Format strings with placeholders (`%s`, `%d`, `%f`, etc.) are assigned to a `$template` + variable before being passed to `sprintf`. The variable assignment and the `sprintf` call live + on separate statements. See "Format strings" for examples. +20. All class references use `use` imports at the top of the file. Fully qualified names inline are + prohibited. +21. Return types and `new` calls use the explicit class name. `self` is prohibited as a type, + as a return type, and in `new self()` instantiation. Constant access via `self::CONST_NAME` + is permitted. `static` is permitted only inside extension-point classes (declared `class` + without `final readonly`) and inside traits, where late static binding lets subclasses or + consuming classes instantiate the correct concrete type. In every other context, use the + class name. +22. Always use the most current and clean syntax available in the target PHP version. Prefer + `match` over `switch`, first-class callables over `Closure::fromCallable()`, readonly promotion + over manual assignment, enum methods over external switch or if chains, named arguments over + positional ambiguity (except where excluded by rule 4), `Collection::map` over foreach + accumulation, and **unparenthesized constructor chaining** (PHP 8.4+): + `new Foo()->bar()` instead of `(new Foo())->bar()`. The parentheses around the `new` + expression are no longer required and add visual noise. +23. All identifiers, comments, and documentation use American English. See "American English" for + the spelling list. -## Casing conventions +## Naming -- Internal code (variables, methods, classes): **`camelCase`**. -- Constants and enum-backed values when representing codes: **`SCREAMING_SNAKE_CASE`**. +- Internal code (variables, methods, classes) uses `camelCase`. +- Constants and enum-backed values when representing codes use `SCREAMING_SNAKE_CASE`. +- Names describe what in domain terms, not how technically. `$monthlyRevenue` instead of + `$calculatedValue`. Generic technical verbs are avoided. See `php-library-modeling.md` for the + full banlist of generic and anemic names. +- Booleans use predicate form. Examples are `isActive`, `hasPermission`, `wasProcessed`. +- Collections are always plural. Examples are `$orders`, `$lines`. +- Methods returning `bool` use prefixes `is`, `has`, `can`, `was`, `should`. -## Naming +## Class self-references + +Type declarations, return types, and `new` calls inside a class use the explicit class name. +The class name is unambiguous, survives refactors that move the method to a different class, +and reads identically inside the class body and at the call site. + +- `self` is prohibited everywhere as a type, as a return type, and in `new self()` + instantiation. Constant access via `self::CONST_NAME` is **permitted**. The prohibition + covers the forms that carry refactoring ambiguity when a method moves to a different class + (the type-or-instantiation forms). Constant access does not have that ambiguity because the + constant is declared in the same class body. +- `static` is permitted only inside extension-point classes (declared `class` without + `final readonly`) and inside traits, where late static binding is required for subclasses or + consuming classes to instantiate the correct concrete type. +- In every other context (the default `final readonly class`, factory methods, return types), + use the class name. + +**Prohibited.** `self` as return type and `new self()` inside a final class: + +```php +final readonly class UserAgent +{ + public static function from(string $product): self + { + return new self(product: $product); + } +} +``` + +**Correct.** Explicit class name in a final class: + +```php +final readonly class UserAgent +{ + public static function from(string $product): UserAgent + { + return new UserAgent(product: $product); + } +} +``` -- 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`. +**Correct.** `static` permitted in an extension-point class: + +```php +class Collection +{ + public static function createFrom(iterable $elements): static + { + return new static(elements: $elements); + } +} +``` + +## Inheritance and constructors + +- 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, for example `Collection` or `ValueObject`. +- Use `final class` without `readonly` only when the parent class is not readonly, for example + when extending a third-party abstract class. +- Use `final class` without `readonly` is also permitted for `src/Internal/` collaborators that + carry intrinsically mutable state (resource handles, counters, cursors) where the mutation is + central to the class's responsibility (`Stream` closing a resource, `Cursor` advancing a + position). The class must remain confined to `src/Internal/`. +- Use `final class` without `readonly` for classes that consist exclusively of `static` methods + (no instance properties, no instance methods, only static factories or utilities). Pair it + with `private function __construct() {}` to prevent instantiation. `readonly` is meaningless + without instance state, and the private constructor signals that the class is a static + surface, not a value type. +- Inheritance between concrete classes is prohibited. Every concrete class is `final`. +- Polymorphism uses interfaces plus composition, never extension of concrete types. +- The only allowed `extends` is against framework or SPL base classes that the language requires. + Examples are `RuntimeException`, `LogicException`, `PHPUnit\Framework\TestCase`. +- Constructors of `final` classes are `private` when paired with named factory methods, `public` + otherwise. `protected` constructors are prohibited because no subclasses exist to call them. ## 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)`. +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`). +All identifiers, enum values, comments, and error codes use American English spelling. Examples +are `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. +### When required + +- Every method of an interface, **including interfaces declared inside `src/Internal/`**. + Interfaces define contracts. The contract is documentation by definition, regardless of + namespace. The `Internal/` boundary applies to implementations, not to the contracts that + internal collaborators expose to each other. +- Every public method of a concrete class outside `src/Internal/`. Public classes are at the + public API boundary by definition. Consumers call every public method directly, and the + PHPDoc is the contract for each call. Trivial getters and `with*` methods are not exempt. + The only exception is a public method whose contract is already documented on an implemented + interface (the interface carries the docblock). + +### When prohibited + +- Constructors. The constructor signature with property promotion is self-documenting. Parameter + types are already explicit in the signature. +- Private and protected methods. +- Public methods of concrete classes whose contract is already documented on an implemented + interface. The interface carries the docblock. +- Anything inside `src/Internal/`. Internal types are implementation detail and must not carry + PHPDoc. The namespace itself is the boundary. See `php-library-architecture.md` for the + architectural meaning of `Internal/`. **Exception**: interfaces and their methods. An + interface declared inside `src/Internal/` still defines a contract, and the contract is + documented per `### When required` regardless of namespace. The prohibition covers concrete + classes, traits, enums, and anonymous classes inside `Internal/`, never interfaces. +- Anywhere inside `tests/`. Test methods name the scenario via the `testXxxWhenYyyGivenThenZzz` + naming convention, and the `@Given`/`@When`/`@Then`/`@And` annotation blocks defined in + `php-library-testing.md` describe the steps. PHPDoc documentation (summary plus + `@param`/`@return` descriptions) is prohibited on test methods, data providers, fixtures, + setUp/tearDown overrides, and anonymous classes inside tests. The BDD annotations are not + PHPDoc documentation in the sense of this section and remain required per the testing rule. +- Single-line PHPDocs with only a tag (`/** @param ... */`, `/** @return ... */`, + `/** @throws ... */`). PHPDoc always opens with a summary line. Bare-tag docblocks are + prohibited regardless of how few tags they carry. + +The prohibitions above apply to **every form of PHPDoc** in the prohibited scope: +method-level docblocks, property-level docblocks, inline `@var` annotations on local variables, +and PHPDoc blocks placed above anonymous functions or closures inside method bodies. Inside +`src/Internal/` and `tests/`, zero PHPDoc is the rule with no exception. PHPStan errors that +result from the missing annotations route through `ignoreErrors` (see below). + +The PHPDoc prohibitions above take priority over the typed-array case. When PHPStan at +`level: max` flags a missing iterable value type (`missingType.iterableValue`, +`argument.type`, `return.type`): + +- On a **constructor parameter** → suppress via `ignoreErrors` in `phpstan.neon.dist`. Do not + add PHPDoc. +- On anything inside **`src/Internal/`** (concrete classes, traits, enums) → suppress via + `ignoreErrors`. Do not add PHPDoc. Interfaces inside `src/Internal/` are the exception: + they carry PHPDoc per `### When required`, and the PHPStan errors they raise are resolved + through the PHPDoc, never through `ignoreErrors`. +- On anything inside **`tests/`** → suppress via `ignoreErrors`. Do not add PHPDoc. +- On a **public method of a public (non-Internal) class** → add full PHPDoc with summary, + `@param` descriptions, and the typed-array information. The bare-tag form remains + prohibited. This is the normal case where PHPDoc is permitted by "When required" above. + +The summary requirement and the bare-tag prohibition are never waived. Use `ignoreErrors` only +when the context (constructor, `src/Internal/`, `tests/`) makes PHPDoc impossible. Every public +method of a public concrete class carries PHPDoc per "When required", whether the method +has typed-array parameters. + +### Style + +- Summary on the first line, in domain terms. **Mandatory.** PHPDoc without a summary line is + prohibited, even when it carries a single `@param` or `@return`. +- Optional detailed body in `

` paragraphs below the summary. +- Tags use the form `@param Type $name Description.`, `@return Type Description.`, + `@throws ExceptionClass If .`. - 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). +- HTML tags allowed inside descriptions are `

` for paragraphs, `

  • ` for lists, + `` for inline code, `` and `` for emphasis. + +### Summary patterns + +The summary line is not a creative intent statement. It is a template selected by the method's +name prefix. Apply the matching template. Only methods with no matching prefix require a +free-form one-line summary in domain terms. + +| Method shape | Template | +|-------------------------------------------------------------------------|--------------------------------------------------------------------------------| +| Static factory (`create`, `from`, `fromX`, `with*` when static) | `Creates a {ClassName} from {input}.` or `Builds a {ClassName} with {fields}.` | +| `with*` instance method | `Returns a copy of the {ClassName} with the {field} replaced.` | +| Getter (no prefix, returns a property: `code()`, `body()`, `headers()`) | `Returns the {field}.` | +| Predicate (`is*`, `has*`, `can*`, `was*`, `should*`) | `Tells whether {condition}.` | +| Converter (`toArray`, `toString`, `asX`) | `Returns the {ClassName} as {target shape}.` | +| `apply*`, `merge*`, `add*`, and other side-effect-free operations | One-line summary in domain terms describing the operation. | + +The patterns are mandatory when applicable. They make summary lines mechanical: substitute +`{ClassName}` and `{field}` and the summary is complete. No per-method intent decision is +required. Volume is never a reason to skip the summary. Many methods just mean applying the +template many times. + +### Cross-references + +- `{@see ClassName}` for links to other types in the codebase. +- `@see Author, Title (Publisher, Year), Chapter X.` for bibliographical references. + +### Examples + +**Prohibited.** Single-line bare-tag PHPDoc, no summary: + +```php +/** @param array|null $body */ +public static function with(Code $code, ?array $body = null): Response +``` + +**Prohibited.** PHPDoc on a constructor: + +```php +/** @param array $entries */ +public function __construct(public array $entries) +{ +} +``` + +**Prohibited.** PHPDoc on a **concrete class** inside `src/Internal/` (the prohibition does +not extend to interfaces; see "Correct" below for an Internal/ interface): + +```php +namespace TinyBlocks\Http\Internal\Client; + +final readonly class Url +{ + /** @param array|null $query */ + public static function compose(string $path, ?array $query, string $baseUrl): string + { + } +} +``` + +**Correct.** Interface declared **inside `src/Internal/`** still carries PHPDoc on every +method. The Internal/ prohibition covers concrete classes; interfaces are exempt because they +are the contract: + +```php +namespace TinyBlocks\Http\Internal\Client; + +interface RequestResolver +{ + /** + * Resolves the given URL against the configured base URL. + * + * @param string $url The path or absolute URL to resolve. + * @return string The absolute URL to dispatch. + * @throws MalformedPath If the URL violates RFC 3986. + */ + public function resolve(string $url): string; +} +``` + +**Correct.** Generic array type with summary and `@param` description: + +```php +/** + * Builds a synthesized response from a status code and an optional body. + * + * @param array|null $body The response body as an associative array. + * @return Response The synthesized response instance. + */ +public static function with(Code $code, ?array $body = null): Response +``` + +**Correct.** Interface with rich description, paragraphs, cross-references, and bibliography: + +```php +/** + * Money tied to a specific currency. + * + *

    Operations between different currencies raise CurrencyMismatch. Arithmetic + * preserves the currency.

    + * + *

    Sibling of {@see Quantity}, not a parent. Money carries currency semantics.

    + * + * @see Eric Evans, Domain-Driven Design (Addison-Wesley, 2003), Chapter 5. + */ +interface Money +{ + /** + * Adds the given amount. + * + * @param Money $other The amount to add. + * @return Money A new instance with the summed amount. + * @throws CurrencyMismatch If $other has a different currency. + */ + public function add(Money $other): Money; +} +``` + +**Correct.** Concrete class with a short summary and direct tags: + +```php +/** + * IANA timezone identifier (e.g. America/Sao_Paulo). + */ +final readonly class Timezone +{ + /** + * Creates a Timezone from a valid IANA identifier. + * + * @param string $identifier The IANA timezone identifier. + * @return Timezone The created instance. + * @throws InvalidTimezone If the identifier is not a valid IANA timezone. + */ + public static function from(string $identifier): Timezone + { + # ... + } +} +``` + +## Dependencies + +When the library needs an external dependency, prefer packages from the `tiny-blocks` ecosystem +(https://github.com/tiny-blocks) whenever a suitable option exists. Reach for outside packages +only when the ecosystem has no equivalent that fits the use case. ## 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. +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` plus accumulation. +The same applies to `filter()`, `reduce()`, `each()`, and every other `Collectible` operation. +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:** +**Prohibited.** `array_map` plus `iterator_to_array` on a `Collectible`: ```php $names = array_map( @@ -145,10 +456,161 @@ $names = array_map( ); ``` -**Correct — fluent chain with `map()` + `toArray()`:** +**Correct.** Fluent chain with `map()` plus `toArray()`: ```php $names = $collection ->map(transformations: static fn(Element $element): string => $element->name()) ->toArray(keyPreservation: KeyPreservation::DISCARD); ``` + +## Format strings + +When building a message with placeholders, assign the format string to a `$template` variable +first. Pass it to `sprintf` on a separate statement. The format and the data are visually +separated, and the template line stays scannable. + +**Prohibited.** Format string inline with the call: + +```php +if ($value < 0 || $value > 16) { + throw new PrecisionOutOfRange( + message: sprintf('Precision must be between 0 and 16, got %d.', $value) + ); +} +``` + +**Correct.** Format string in a `$template` variable: + +```php +if ($value < 0 || $value > 16) { + $template = 'Precision must be between 0 and 16, got %d.'; + + throw new PrecisionOutOfRange(message: sprintf($template, $value)); +} +``` + +## Constructor chaining + +PHP 8.4 allows chained method calls directly on a `new` expression without wrapping it in +parentheses. The parentheses are no longer required and only add visual noise. Apply this +everywhere a `new` is followed by a method call. + +**Prohibited.** Parentheses around the `new` expression: + +```php +$body = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withHeader('Accept', 'application/json') + ->getBody(); +``` + +**Correct.** No parentheses: + +```php +$body = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withHeader('Accept', 'application/json') + ->getBody(); +``` + +## Formatting overrides + +Three formatting rules are not covered by the canonical `phpcs.xml` (which references `PSR-12` +only). Apply them manually. + +### No vertical alignment in parameter lists + +Use a single space between the type and the variable name in parameter lists (constructors, +function signatures, closures). Never pad with extra spaces to align columns. This rule applies +only to parameter lists, not to other contexts that use `=>` alignment (see "Vertical alignment +of `=>`" below). + +**Prohibited.** Vertical alignment of types: + +```php +public function __construct( + public OrderId $id, + public Money $total, + public Customer $customer, + public Precision $precision +) {} +``` + +**Correct.** Single space between type and variable: + +```php +public function __construct( + public OrderId $id, + public Money $total, + public Customer $customer, + public Precision $precision +) {} +``` + +### Vertical alignment of `=>` in match arms and array literals + +Multi-line `match` expressions and multi-line array literals with `=>` align the `=>` column +across all arms or entries by padding shorter left-hand sides with spaces. Single-line cases +(one-arm match, single-line array) keep the standard PSR-12 single-space form. + +**Prohibited.** Unaligned `=>` in match: + +```php +return match ($this) { + self::MAX_AGE => sprintf($template, $this->value, $value), + default => $this->value +}; +``` + +**Correct.** Aligned `=>` in match: + +```php +return match ($this) { + self::MAX_AGE => sprintf($template, $this->value, $value), + default => $this->value +}; +``` + +**Prohibited.** Unaligned `=>` in array literal: + +```php +return [ + 'name' => 'Gustavo', + 'role' => 'developer', + 'company' => 'Anthropic' +]; +``` + +**Correct.** Aligned `=>` in array literal: + +```php +return [ + 'name' => 'Gustavo', + 'role' => 'developer', + 'company' => 'Anthropic' +]; +``` + +### No trailing comma in multi-line lists + +Never place a trailing comma after the last element of any multi-line list. Applies to parameter +lists, argument lists, array literals, match arms, and every other comma-separated multi-line +structure. PHP accepts trailing commas in these positions, but this ecosystem prohibits them for +visual consistency. + +**Prohibited.** Trailing comma after the last argument: + +```php +new Precision( + value: 2, + rounding: RoundingMode::HALF_UP, +); +``` + +**Correct.** No trailing comma: + +```php +new Precision( + value: 2, + rounding: RoundingMode::HALF_UP +); +``` diff --git a/.claude/rules/php-library-commits.md b/.claude/rules/php-library-commits.md new file mode 100644 index 0000000..feefcf5 --- /dev/null +++ b/.claude/rules/php-library-commits.md @@ -0,0 +1,111 @@ +--- +description: Conventional Commits format. Applied on request when generating commit messages. +--- + +# Commits + +Applied only when generating commit messages, never automatically. All commit messages are +written in English. + +## Format + +`: ` + +The description starts with a capital letter, uses imperative present tense ("Add", "Fix", +"Change", not "Added", "Adds", or "Adding"), and ends with a period. Subject under 300 +characters. If it does not fit, split the change into multiple commits or move detail into the +body. + +Scopes are prohibited. `feat(orders): ...` is wrong. The type stands alone. + +## Allowed types + +Each entry below is a bullet that starts with a capital letter and ends with a period. This is +the canonical example of bullet punctuation enforced everywhere in this document. + +- `ci` for CI configuration changes. +- `fix` for a bug fix. +- `feat` for a user-facing feature. +- `docs` for documentation only. +- `test` for adding or correcting tests. +- `chore` for maintenance with no production code change. +- `build` for build or dependency changes. +- `revert` for reverting a previous commit. +- `refactor` for a code change that neither fixes a bug nor adds a feature. + +`style` is not used. Formatting is enforced by the linter and never appears as a standalone +commit. + +## Subject examples + +Good: + +- `fix: Handle zero-amount transactions.` +- `feat: Add order cancellation endpoint.` +- `refactor: Extract OrderStatus into its own enum.` + +Bad: + +- `Added order cancellation` is past tense, missing type, missing period. +- `feat: Adds order cancellation.` is third-person singular instead of imperative. +- `feat: added order cancellation.` starts lowercase and is past tense. +- `feat: Add cancellation, and fix billing rounding.` bundles two changes. Split. +- `feat(orders): Add cancellation.` uses a scope. Prohibited. + +## Body + +The body is **optional and rarely needed**. Single-purpose commits never have a body. Add a body +ONLY when the reason cannot be inferred from the diff (a non-obvious trade-off, a workaround for +an external bug, a decision worth recording). + +Separate the body from the subject with a blank line. Wrap at 72 characters per line. Explain +why, not what. The diff already shows what. + +## Prose vs. bullets in the body + +**Default to prose.** One or two paragraphs fits almost every commit that has a body at all. + +**Use bullets only when ALL of these are true:** + +1. The commit covers 3 or more independent changes that genuinely belong in the same commit. +2. The list cannot be expressed as continuous prose without becoming disconnected sentences. +3. Each item is independently meaningful (no sub-bullets, no continuation across bullets). + +A two-item bullet list is the wrong shape. Use prose. + +## Bullet formatting (when used) + +Every bullet starts with a capital letter and ends with a period. Imperative verb in present +tense, same as the subject line. Without exception. + +Wrong (do NOT generate): + +- `add the OrderCancelling port` lowercase, missing period. +- `Add the OrderCancelling port` capital but missing period. +- `Adds the OrderCancelling port.` third-person singular instead of imperative. + +## Body example with bullets + +``` +feat: Add order cancellation flow. + +- Add the OrderCancelling inbound port and OrderCancellingHandler. +- Add the CancelOrder command and its validator. +- Cover the cancellation path in the integration test suite. +``` + +## Body example with prose (preferred for most commits) + +``` +fix: Handle zero-amount transactions. + +The payment gateway rejects zero-amount charges with a generic 400 instead +of a documented error code, so the adapter short-circuits before the HTTP +call and raises ZeroAmountNotAllowed directly. +``` + +## Commit splitting + +Prefer one logical change per commit. Refactor commits never modify behavior. When a task +requires multiple types of change, produce multiple commits in order: `refactor` first, then +`feat` or `fix` on top. diff --git a/.claude/rules/php-library-documentation.md b/.claude/rules/php-library-documentation.md index d7ac6da..b7e0da4 100644 --- a/.claude/rules/php-library-documentation.md +++ b/.claude/rules/php-library-documentation.md @@ -1,40 +1,313 @@ --- -description: Standards for README files and all project documentation in PHP libraries. +description: Standards for README and other public-facing Markdown docs in PHP libraries. paths: - "**/*.md" --- # Documentation +Standards for `README.md` and other public-facing Markdown files in the repository. PHPDoc rules +for `.php` files live in `php-library-code-style.md`. American English applies everywhere (see +the American English section in `php-library-code-style.md`). + +The `CONTRIBUTING.md` file is centralized at +`https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md`. Each library's README and +pull request template link to that location. No local `CONTRIBUTING.md` is created per library. + +## Pre-output checklist + +Verify every item before producing any Markdown documentation. If any item fails, revise before +outputting. + +1. README title is `# ` with spaces between words (`# Building Blocks`, not + `# BuildingBlocks`). +2. License badge is the only badge. No build, coverage, Packagist, or version badges. +3. Header is followed by an anchor-linked table of contents. +4. Table of contents uses `*` for top-level (H2) entries, `+` indented by 4 spaces for + second-level (H3) entries, and `-` indented by 8 spaces for third-level (H4) entries. Every + heading from the document appears in the TOC, except FAQ entries: the FAQ is represented by + a single `* [FAQ](#faq)` line regardless of how many questions it contains. +5. Sections appear in the canonical order: Overview, Installation, How to use, FAQ (optional), + License, Contributing. +6. FAQ exists only when there are genuine points of confusion or unusual design decisions. Skip + it entirely when not needed. +7. **Self-contained code examples** are blocks that include any of: a `use` statement, a + `class`/`enum`/`interface`/`trait`/`function` declaration, or more than 3 lines of + executable code. Self-contained blocks open with `?` with zero-padded numbering + (`### 01.`, `### 02.`). +12. FAQ bibliographic citations use the format + `> Author, *Title* (Publisher, Year), Chapter X, "Section Name".` +13. License and Contributing sections each follow the canonical one-line template. +14. Repository includes `SECURITY.md`, `.github/ISSUE_TEMPLATE/bug_report.md`, + `.github/ISSUE_TEMPLATE/feature_request.md`, and `.github/PULL_REQUEST_TEMPLATE.md`, each + matching the canonical template in "Other documentation files". + ## 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. +### Structure + +The README follows a fixed section order: + +1. **Overview**. One or more paragraphs explaining the problem the library solves and its design + philosophy. Cross-references to related `tiny-blocks` libraries belong here. +2. **Installation**. Composer command in a code block, with no surrounding prose unless strictly + necessary. +3. **How to use**. Runnable examples covering the primary use cases. Each subsection demonstrates + one capability with a heading and a self-contained code block. +4. **FAQ** (optional). Numbered questions that address real points of confusion or unusual design + decisions. +5. **License**. One-line link to the `LICENSE` file. +6. **Contributing**. One-line link to the centralized `CONTRIBUTING.md` in + `tiny-blocks/tiny-blocks`. + +### Header and license badge + +The first line is `# ` followed by a blank line and the license badge: + +```markdown +# Outbox + +[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/tiny-blocks//blob/main/LICENSE) +``` + +Replace `` with the library's repository name. The badge is the only badge in the document. + +### Table of contents + +The table of contents is anchor-linked. Top-level (H2) entries use `*`. Second-level (H3) +entries use `+` indented by 4 spaces. Third-level (H4) entries use `-` indented by 8 spaces. +Every heading from the document appears, with one exception: the FAQ is represented by a single +`* [FAQ](#faq)` line. Its questions never appear as TOC sub-entries, regardless of how many +exist. + +```markdown +* [Overview](#overview) +* [Installation](#installation) +* [How to use](#how-to-use) + + [Subtopic A](#subtopic-a) + + [Subtopic B](#subtopic-b) +* [FAQ](#faq) +* [License](#license) +* [Contributing](#contributing) +``` + +Use the third level whenever the document has H4 headings, regardless of whether they form a +two-axis split. The TOC mirrors the document structure exactly. + +```markdown +* [How to use](#how-to-use) + + [Entity](#entity) + - [Single-field identity](#single-field-identity) + - [Compound identity](#compound-identity) + + [Aggregate](#aggregate) +``` + +### Code examples + +Code examples fall into two categories. + +**Self-contained examples** include at least one of: + +- A `use` statement. +- A `class`, `enum`, `interface`, `trait`, or `function` declaration. +- More than 3 lines of executable code. + +They open with `push(records: $order->recordedEvents()); +``` + +**Inline fragment examples** have all of: + +- At most 3 lines of executable code. +- No `use` statements. +- No type declarations. + +Fragments may omit the prologue. + +```php +Code::OK->value; +``` + +The criteria are mechanical: a block that meets any self-contained condition gets the prologue. A block that meets every fragment condition may omit it. There is no middle ground. + +The `#` convention for inline comments applies only to code examples inside Markdown files. PHP +files under `src/` and `tests/` have no inline comments at all, except `# TODO: ` (see +item 16 in `php-library-code-style.md`). + +### FAQ + +FAQ entries are numbered with zero-padded prefixes and end with a question mark: + +```markdown +### 01. Why is DomainEvent close to a marker interface? + +A domain event is a fact about something that happened in the domain. The contract carries only +`revision()` so the library can route schema migrations through upcasters. Everything else +(aggregate identity, sequence number, aggregate type, occurrence timestamp) is envelope metadata +that belongs to `EventRecord`. + +> Vaughn Vernon, *Implementing Domain-Driven Design* (Addison-Wesley, 2013), Chapter 8, +> "Domain Events". +``` + +Bibliographic citations follow the format +`> Author, *Title* (Publisher, Year), Chapter X, "Section Name".` The chapter and section +fragments are optional when the title is precise enough on its own. Multiple citations can be +stacked as separate blockquote lines. + +### License and Contributing + +The License section is a single line: + +```markdown +## License + + is licensed under [MIT](LICENSE). +``` + +The Contributing section is a single line pointing to the centralized guideline: + +```markdown +## Contributing + +Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to +contribute to the project. +``` ## 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. +Tables are preferred to prose for any structured information: constructor parameter lists, +builder method catalogs, default value tables, complexity tables, and configuration matrices. +Column layout is chosen per case. No fixed column set is mandated. + +## Other documentation files + +Every library repository includes the following files in addition to the README. Each follows +the canonical template below. + +### SECURITY.md + +```markdown +# 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//security/advisories/new). + +Please do not disclose the vulnerability publicly until it has been addressed. +``` + +Replace `` with the repository name. + +### .github/ISSUE_TEMPLATE/bug_report.md + +```markdown +--- +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: +``` + +### .github/ISSUE_TEMPLATE/feature_request.md + +```markdown +--- +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. +``` + +### .github/PULL_REQUEST_TEMPLATE.md + +```markdown +> 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 #... -## Style +## Checklist -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. +- [ ] Tests added or updated. +- [ ] Documentation updated when applicable. +- [ ] `composer review` passes. +- [ ] `composer tests` passes. +``` diff --git a/.claude/rules/php-library-github-workflows.md b/.claude/rules/php-library-github-workflows.md new file mode 100644 index 0000000..396c40a --- /dev/null +++ b/.claude/rules/php-library-github-workflows.md @@ -0,0 +1,287 @@ +--- +description: Structure, ordering, and pinning rules for GitHub Actions workflows in PHP libraries. +paths: + - ".github/workflows/**/*.yml" + - ".github/workflows/**/*.yaml" +--- + +# Workflows + +Conventions for GitHub Actions workflows in PHP libraries. CD does not apply. Libraries publish +to Packagist via tags and never deploy. + +`.github/workflows/ci.yml` is mandatory and follows the canonical structure defined in the +"ci.yml" section below. Additional workflow files (security scanning, automated triage, +scheduled tasks, dependency updates, etc.) may exist and follow the general rules in this file. +Their trigger, job structure, and steps are chosen by their purpose. + +The Composer scripts invoked by `ci.yml` (`composer review`, `composer tests`) are defined in +`php-library-tooling.md`. + +## Pre-output checklist + +Verify every item before producing or editing any workflow YAML. If any item fails, revise +before outputting. + +### Rules for every workflow + +These rules apply to `ci.yml` and to every additional workflow in `.github/workflows/`. + +1. Keys at the workflow root follow the canonical order `name`, `on`, `concurrency`, + `permissions`, `jobs`. Keys absent in a given workflow are simply omitted. The relative order + of the remaining keys is preserved. +2. Properties inside a job follow the canonical order `name`, `needs`, `runs-on`, + `timeout-minutes`, `outputs`, `env`, `steps`. Same omission rule as above. +3. Inside any block (`env`, `outputs`, `with`, `permissions`), entries are ordered by key length + ascending. +4. The workflow `name`, every job `name`, and every step `name` are mandatory and use sentence + case (`Resolve PHP version`, not `RESOLVE_PHP_VERSION` or `resolve_php_version`). Step names + start with a verb. Job keys describe the job's purpose. Generic keys (`run`, `job`, `do`) are + discouraged in favor of descriptive identifiers (`auto-assign`, `analyze`, `notify`). +5. `concurrency` is set at the workflow root with `cancel-in-progress: true` and a `group` + expression scoped by the workflow's trigger: + - `pull_request`: `-${{ github.event.pull_request.number }}`. + - `issues`, or `issues` combined with `pull_request`: + `-${{ github.event.issue.number || github.event.pull_request.number }}`. + - `push`, `schedule`, or both: `-${{ github.ref }}`. + + `` is the workflow's short name (`ci`, `codeql`, `auto-assign`). +6. `permissions` is declared at the workflow root with the minimum scope every job needs. + Job-level `permissions` blocks are allowed only when a specific job needs a narrower scope + than the root, never broader. +7. Every job sets `timeout-minutes`. Defaults: 5 for trivial steps (single API call, lightweight + script), 15 for jobs with PHP setup or test runs, 30 for analysis-heavy jobs (CodeQL, + security scanning). Adjust based on observed runtime when prior runs exist. +8. Every action is pinned to a fixed major version tag written explicitly. Examples are + `actions/checkout@v6` and `shivammathur/setup-php@v2`. Never use `@latest`, `@main`, a branch + name, or a commit SHA. When the existing pin is an explicit minor or patch, derive the major + version while **preserving the prefix style** of the original tag: `@v2.1.0` → `@v2`, + `@2.1.0` → `@2`. The action's tag convention is reflected in the existing pin. Web lookup is + required only when the existing pin is missing, ambiguous, or pointing to a non-version + reference. Example versions cited in this file may be outdated and are not a license to skip + the lookup when it is required. +9. Inline shell logic longer than 3 lines is extracted to a script in `scripts/ci/`. +10. All text (workflow name, job names, step names, comments) uses American English with correct + spelling and punctuation. Sentences and descriptions end with a period. + +### Rules specific to ci.yml + +These rules apply only to `.github/workflows/ci.yml`. Additional workflows are not bound by them. + +1. File path is `.github/workflows/ci.yml`. The workflow `name` field is exactly `CI`. +2. Trigger is `pull_request` only. No `push`, no branch filter, no `workflow_dispatch`. +3. Jobs run in the fixed sequence `resolve-php-version`, `build`, `auto-review`, `tests`. Each + downstream job lists its upstream jobs in `needs`. +4. PHP version is never hardcoded. The `resolve-php-version` job reads `.require.php` from + `composer.json` at runtime and exposes the minor version (for example, `8.5`) as the job + output `php-version`. Downstream jobs reference + `${{ needs.resolve-php-version.outputs.php-version }}` when setting up PHP. +5. The `auto-review` job runs `composer review`. The `tests` job runs `composer tests`. Both + scripts are defined in `composer.json` per `php-library-tooling.md`. No other command is + invoked in either job. +6. The `build` job uploads `vendor/` and `composer.lock` as a single artifact named + `vendor-artifact`. The `auto-review` and `tests` jobs download that artifact instead of + running `composer install` again. +7. The `tests` job is the only job that may extend with extra setup required by the library, + such as service containers, fixture preparation, or environment variables used during + testing. The other three jobs are identical across every library in the ecosystem. +8. `concurrency.group` is `pr-${{ github.event.pull_request.number }}`. `timeout-minutes` is 5 + for `resolve-php-version` and 15 for `build`, `auto-review`, and `tests`. `permissions` is + `contents: read`. + +## ci.yml + +`ci.yml` is the mandatory workflow that gates every pull request. It contains four jobs in the +exact order below. The first three jobs are identical across every library. Only `tests` may +extend with extra setup required by the library. + +### Resolve PHP version + +Reads `.require.php` from `composer.json` and exposes the minor version (for example, `8.5`) as the +output `php-version`. A single step uses `jq` and a short regex to extract the value. Downstream jobs +consume the output to configure their PHP setup. + +### Build + +Sets up PHP using the resolved version, validates `composer.json`, installs dependencies with +`--no-progress --optimize-autoloader --prefer-dist --no-interaction`, and uploads `vendor/` and +`composer.lock` as the artifact `vendor-artifact`. + +### Auto review + +Depends on `resolve-php-version` and `build`. Downloads `vendor-artifact`, sets up PHP, and runs +`composer review`. The `review` script in `composer.json` aggregates lint, static analysis, and style +checks for the library. + +### Tests + +Depends on `resolve-php-version` and `auto-review`. Downloads `vendor-artifact`, sets up PHP, and runs +`composer tests`. Any setup required by the library's tests (service containers, fixture preparation, +environment variables used during testing) lives in this job only. + +## Reference shape + +The YAML below is the canonical minimal form. Every library starts from this exact shape and extends +only the `tests` job when its tests require extra setup. Action versions cited here may be outdated. +Look up the current major version of every action via web search before adopting this shape verbatim. + +### Minimal workflow + +```yaml +name: CI + +on: + pull_request: + +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + resolve-php-version: + name: Resolve PHP version + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + php-version: ${{ steps.config.outputs.php-version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Resolve PHP version from composer.json + id: config + run: | + version=$(jq -r '.require.php' composer.json | grep -oP '\d+\.\d+' | head -1) + echo "php-version=$version" >> "$GITHUB_OUTPUT" + + build: + name: Build + needs: resolve-php-version + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} + + - name: Validate composer.json + run: composer validate --no-interaction + + - name: Install dependencies + run: composer install --no-progress --optimize-autoloader --prefer-dist --no-interaction + + - name: Upload vendor and composer.lock as artifact + uses: actions/upload-artifact@v7 + with: + name: vendor-artifact + path: | + vendor + composer.lock + + auto-review: + name: Auto review + needs: [resolve-php-version, build] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} + + - name: Download vendor artifact from build + uses: actions/download-artifact@v8 + with: + name: vendor-artifact + path: . + + - name: Run review + run: composer review + + tests: + name: Tests + needs: [resolve-php-version, auto-review] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} + + - name: Download vendor artifact from build + uses: actions/download-artifact@v8 + with: + name: vendor-artifact + path: . + + - name: Run tests + run: composer tests +``` + +### Extending the tests job + +When the library's tests need external services, env vars, or fixture preparation, the additions live +inside the `tests` job only. The example below shows the same `tests` job extended with a MySQL service +container and the env vars consumed by the test suite. + +```yaml +tests: + name: Tests + needs: [resolve-php-version, auto-review] + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + DB_HOST: 127.0.0.1 + DB_NAME: library_test + DB_PORT: '3306' + DB_USER: library + DB_PASSWORD: library + services: + mysql: + image: mysql:8 + ports: + - 3306:3306 + env: + MYSQL_DATABASE: library_test + MYSQL_ROOT_PASSWORD: library + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} + + - name: Download vendor artifact from build + uses: actions/download-artifact@v8 + with: + name: vendor-artifact + path: . + + - name: Run tests + run: composer tests +``` diff --git a/.claude/rules/php-library-modeling.md b/.claude/rules/php-library-modeling.md index bedb733..127413c 100644 --- a/.claude/rules/php-library-modeling.md +++ b/.claude/rules/php-library-modeling.md @@ -1,112 +1,199 @@ --- -description: Library modeling rules — folder structure, public API boundary, naming, value objects, exceptions, enums, extension points, and complexity. +description: Semantic modeling rules for PHP libraries (nomenclature, value objects, exceptions, enums, extension points, complexity). paths: - "src/**/*.php" --- -# Library modeling +# Modeling + +Library modeling rules. How to model the concepts the library exposes. Folder structure and +public API boundary live in `php-library-architecture.md`. Code style lives in +`php-library-code-style.md`. Tooling lives in `php-library-tooling.md`. + +## Pre-output checklist + +Verify every item before producing any PHP code that defines a model, an exception, or an +algorithm. If any item fails, revise before outputting. + +1. Each model has a single, clear responsibility. Apply DDD, SOLID, DRY, and KISS where they + sharpen the design, not as dogma. +2. Concept names. Every class, property, method, and exception name reflects the concept the + library represents, not a technical role. +3. No always-banned names. Never use `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity` as + class suffix, prefix, or method name. Never use `Exception` as a class suffix. Exception: + names that correspond to externally standardized identifiers (HTTP status text from RFC + documents, PSR interface names being mirrored, etc.) are permitted. The standard reference + is the meaning carrier. +4. No anemic verbs as the primary operation name (`ensure`, `validate`, `check`, `verify`, + `assert`, `mark`, `enforce`, `sanitize`, `normalize`, `compute`, `transform`, `parse`) unless + the verb is the library's reason to exist. +5. Architectural role names (`Manager`, `Handler`, `Processor`, `Service`, and their verb forms + `process`, `handle`, `execute`) are allowed only when the class IS that role for consumers + integrating with the library. +6. Value objects are immutable. No setters. Operations return new instances. +7. Value objects compare by value, never by reference. No identity field. +8. Value objects validate invariants in the constructor and throw a dedicated exception on + invalid input. +9. Value objects with multiple creation paths use static factory methods (`from`, `of`, `zero`) + with a private constructor. +10. Every failure throws a dedicated exception class named after the invariant it guards. Never + `throw new DomainException(...)`, `throw new InvalidArgumentException(...)`, or any other + generic native exception directly. +11. Dedicated exception classes extend the appropriate native PHP exception (`DomainException`, + `InvalidArgumentException`, `OverflowException`, etc.). +12. Exceptions are pure. No transport-specific fields (HTTP status in `code`, formatted message + for end-user display). They signal invariant violations only, never control flow. +13. Enums are PHP backed enums. They include methods only when those methods carry vocabulary + meaning. +14. Extension points use `class` instead of `final readonly class`. They expose a private + constructor with static factory methods as the only creation path. Internal state is + injected via the constructor. +15. Algorithms run in O(N) or O(N log N) unless the problem inherently requires worse. O(N²) + or worse needs explicit justification. +16. Prefer lazy or streaming evaluation over materializing intermediate results. Memory usage + is bounded and proportional to the output, not to the sum of intermediate stages. + +## Modeling principles + +Apply the following principles where they sharpen the design. Treat them as guides, not as dogma. + +- Single responsibility. Each model represents one concept, has one reason to change, and + exposes operations that belong to that concept. +- DDD ubiquitous language. Names, types, and operations match the vocabulary the library's + domain uses. Code and conversation share the same terms. +- SOLID. Interfaces define narrow contracts. Composition is preferred to inheritance. + Substitutability holds at every interface boundary. +- DRY. No duplicated logic across two or more places. +- KISS. No abstraction without real duplication or isolation need. -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. +## Nomenclature -## Folder structure +- Every class, property, method, and exception name reflects the concept the library represents. + A math library uses `Precision` and `RoundingMode`. A money library uses `Currency` and + `Amount`. A collection library uses `Collectible` and `Order`. +- Name classes after what they represent, not after what they do technically. Use `Money`, + `Color`, `Pipeline`, not `MoneyCalculator`, `ColorHelper`, `PipelineProcessor`. +- Name methods after the operation in the library's vocabulary. Use `add()`, `convertTo()`, + `splitAt()`, not `compute()`, `process()`, `handle()`. -``` -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) -``` +### Always banned -Never use `Models/`, `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. +These names carry zero semantic content. Never use them anywhere as class suffix, prefix, or +method name. -## Public API boundary +- `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`. +- `Exception` as a class suffix (e.g., `FooException`). Use the invariant name when extending a + native exception (e.g., `PrecisionOutOfRange`, not `InvalidPrecisionException`). -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. +### Externally standardized names (exception to the banlist) -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. +Names that correspond to externally standardized identifiers are exempt from the banlist. The +standard reference is the meaning carrier. Renaming weakens it. Examples: -## Nomenclature +- HTTP status text from RFC documents (`unprocessableEntity` from RFC 4918, `noContent`). +- PSR interface names being mirrored as test doubles (`ClientException` mirroring + `Psr\Http\Client\ClientExceptionInterface`). +- Unicode category names, locale identifiers, MIME type tokens, and similar registered names. -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()`. +This exception applies only when the external standard is the actual source of the name. It +does not authorize using `Data` or `Entity` as generic suffixes when no external reference is +involved. -### Always banned +### Anemic verbs -These names carry zero semantic content. Never use them anywhere, as class suffixes, prefixes, or method names: +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()`). -- `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`. -- `Exception` as a class suffix (e.g., `FooException` — use `Foo` when it already extends a native exception). +- `ensure`, `validate`, `check`, `verify`, `assert`, `mark`, `enforce`, `sanitize`, `normalize`, + `compute`, `transform`, `parse`. -### Anemic verbs (banned by default) +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. -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()`): +### Architectural roles -- `ensure`, `validate`, `check`, `verify`, `assert`, `mark`, `enforce`, `sanitize`, `normalize`, `compute`, - `transform`, `parse`. +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). -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). +- `Manager`, `Handler`, `Processor`, `Service`. +- Verb forms: `process`, `handle`, `execute`. -### Architectural roles (allowed with justification) +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. -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): +## Value objects -- `Manager`, `Handler`, `Processor`, `Service`, and their verb forms `process`, `handle`, `execute`. +- Are immutable. No setters. No mutation after construction. Operations return new instances. +- Compare by value, not by reference. +- Validate invariants in the constructor and throw a dedicated exception on invalid input. +- Have no identity field. +- Use static factory methods (`from`, `of`, `zero`) with a private constructor when multiple + creation paths exist. The factory name communicates the semantic intent. -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. +**Prohibited.** Public constructor with multiple creation paths. Semantics are unclear at the +call site: -## Value objects +```php +final readonly class Money +{ + public function __construct(public int $amount, public Currency $currency) {} +} + +new Money(amount: 1000, currency: Currency::BRL); +new Money(amount: 0, currency: Currency::USD); +``` + +**Correct.** Private constructor with named factory methods. Each factory name communicates +intent: + +```php +final readonly class Money +{ + private function __construct(public int $amount, public Currency $currency) {} -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. + public static function of(int $amount, Currency $currency): Money + { + return new Money(amount: $amount, currency: $currency); + } + + public static function zero(Currency $currency): Money + { + return new Money(amount: 0, currency: $currency); + } +} + +Money::of(amount: 1000, currency: Currency::BRL); +Money::zero(currency: Currency::USD); +``` ## 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: +- 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 directly. If the + invariant is worth throwing for, it is worth a named class. +- 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. +- 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. +- Exceptions signal invariant violations only, not control flow. +- Name the class after the invariant violated, never after the technical type. Use + `PrecisionOutOfRange`, not `InvalidPrecisionException`. Use `CurrencyMismatch`, not + `BadCurrencyException`. Use `ContainerWaitTimeout`, not `TimeoutException`. +- A descriptive `message` argument is allowed and encouraged when it carries debugging context + (the violating value, the boundary 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. Keep messages short, factual, and in American English. + +**Prohibited.** Throwing a native exception directly: ```php if ($value < 0) { @@ -114,50 +201,76 @@ if ($value < 0) { } ``` -**Correct** — dedicated class, no message (class name is sufficient): +**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: +**Correct.** Dedicated class with debugging context in the message: ```php if ($value < 0 || $value > 16) { - throw new PrecisionOutOfRange(sprintf('Precision must be between 0 and 16, got %d.', $value)); + $template = 'Precision must be between 0 and 16, got %d.'; + + throw new PrecisionOutOfRange(message: sprintf($template, $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/`. +- Are PHP backed enums. +- Include methods only when those methods carry vocabulary meaning. Examples are + `Order::ASCENDING_KEY` and `RoundingMode::apply()`. ## 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. +- A class designed to be extended by consumers (e.g., `Collection`, `ValueObject`) uses `class` + instead of `final readonly class`. All other classes use `final readonly class`. See + "Inheritance and constructors" in `php-library-code-style.md`. +- Extension point classes use a private constructor with static factory methods (`createFrom`, + `createFromEmpty`) as the only creation path. +- 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. +- Algorithms run in O(N) or O(N log N) unless the problem inherently requires worse. O(N²) or + worse needs explicit justification at the point of definition. +- Prefer lazy or streaming evaluation over materializing intermediate results. In pipeline-style + libraries, fuse stages so a single pass suffices over the input. +- Memory usage is bounded and proportional to the output, not to the sum of intermediate stages. +- Never re-iterate the same source. When a sequence is consumed once, use lazy creation + primitives (`createLazyFrom`) instead of materializing. + +**Prohibited.** Eager pipeline that materializes between stages: + +```php +$paidTotals = array_map( + static fn(Order $order): float => $order->total(), + array_filter( + $orders->toArray(), + static fn(Order $order): bool => $order->isPaid() + ) +); +``` + +Each stage allocates a full intermediate array. Memory grows with the input size, even when only +the final scalar matters. + +**Correct.** Fused pipeline that runs in a single pass: + +```php +$paidTotals = $orders + ->filter(predicates: static fn(Order $order): bool => $order->isPaid()) + ->map(transformations: static fn(Order $order): float => $order->total()) + ->toArray(keyPreservation: KeyPreservation::DISCARD); +``` + +Operations stack on the same iterator. No intermediate array is built. Memory stays bounded by +the final output. diff --git a/.claude/rules/php-library-testing.md b/.claude/rules/php-library-testing.md index 610b928..86a0c10 100644 --- a/.claude/rules/php-library-testing.md +++ b/.claude/rules/php-library-testing.md @@ -1,17 +1,79 @@ --- -description: BDD Given/When/Then structure, PHPUnit conventions, test organization, and fixture rules for PHP libraries. +description: BDD Given/When/Then structure, PHPUnit conventions, fixture rules, and coverage discipline. paths: - "tests/**/*.php" --- -# Testing conventions +# Testing -Framework: **PHPUnit**. Refer to `php-library-code-style.md` for the code style checklist, which also applies to -test files. +PHPUnit conventions for tests in PHP libraries. Covers BDD structure, fixture rules, and coverage +discipline. Code style applies to test files as well. See `php-library-code-style.md`. Folder +structure for `tests/` lives in `php-library-architecture.md`. Canonical thresholds (MSI 100, +covered MSI 100) live in `php-library-tooling.md`. + +## Pre-output checklist + +Verify every item before producing any test code. If any item fails, revise before outputting. + +1. Each test contains exactly one `@When` block. Two actions require two tests. +2. Use `@And` for complementary preconditions or actions within the same scenario, avoiding + consecutive `@Given` or `@When` tags. +3. 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. **Exception for data-provider tests.** When the test method binds its + inputs through a `#[DataProvider]` attribute (or the equivalent `@dataProvider` annotation), + the `@Given` block may declare the input shape in prose form, without an expression below + it. The values are bound by PHPUnit before the test body runs, so the prose annotation + replaces the assignment that would otherwise sit under the `@Given`. + + `@When` blocks follow the same one-expression rule by default: the block represents the + single action under test. **Exception for repeated-invocation tests** (idempotence, caching, + memoization). When the purpose of the test is asserting that the same operation produces the + same outcome across N invocations, the `@When` block may contain N consecutive identical + invocations, each captured in a numbered variable (`$first`, `$second`, ...), and the + annotation reads `@When invoked twice` (or thrice, etc.) to make the composite-action + semantic explicit. Two unrelated actions still require two tests. +4. No intermediate variables used only once. Chain method calls when the intermediate state is + not referenced elsewhere (e.g., `Money::of(...)->add(...)` instead of + `$money = Money::of(...)` followed by `$money->add(...)`). +5. No private or helper methods in test classes. The only non-test methods allowed are data + providers. Setup logic complex enough to extract belongs in a dedicated fixture class. +6. Test only the public API. Never assert on private state or `Internal/` classes directly. +7. Test the behavior that **raises** an exception, never the exception itself. Exception classes + represent invariant violations and are value objects, not the subject of behavior tests. A + test constructs the conditions, invokes the public method that is supposed to fail, and + asserts the expected exception class is raised (plus its accessor values when they carry + information relevant to the failure). Constructing an exception directly + (`new HttpRequestInvalid(...)`) and asserting on its accessors is **prohibited**: the + exception's structure is exercised through the call path that produces it. If a method does + not exist whose call path produces the exception, the exception is dead code and should be + removed. +8. Never mock internal collaborators. Use real objects. Test doubles are used only at system + boundaries (filesystem, clock, network) when the library interacts with external resources. +9. Name tests after behavior, not method names. +10. Use domain-specific names in variables and properties. Never `$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. +11. Annotations use domain language. Write `/** @Given a collection of amounts */`, not + `/** @Given a mocked collection in test state */`. +12. Never use the `/** @test */` annotation. Test methods are discovered by the `test` prefix in + the method name. +13. Never use named arguments on PHPUnit assertions (`assertEquals`, `assertSame`, `assertTrue`, + `expectException`, etc.). Pass arguments positionally. +14. Never include conditional logic inside tests. Each `@Then` block expresses one logical + concept. The only allowed `try`/`catch` is when the assertion target is a property of the + caught exception that cannot be expressed via `expectException*` methods (notably + `getPrevious()` for chain inspection). The catch block contains only assertions against the + caught exception, no branching. +15. Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from + coverage. Never suppress mutants via `infection.json.dist` or any other mechanism. See + "Coverage and mutation discipline". ## Structure: Given/When/Then (BDD) -Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments without exception. +Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments +without exception. ### Happy path example @@ -20,26 +82,30 @@ public function testAddMoneyWhenSameCurrencyThenAmountsAreSummed(): void { /** @Given two money instances in the same currency */ $ten = Money::of(amount: 1000, currency: Currency::BRL); + + /** @And another money instance with the same currency */ $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()); + self::assertEquals(1500, $total->amount()); } ``` ### Exception example -When testing that an exception is thrown, place `@Then` (expectException) **before** `@When`. PHPUnit requires this -ordering. +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); + + /** @And another money instance with a different currency */ $usd = Money::of(amount: 500, currency: Currency::USD); /** @Then an exception indicating currency mismatch should be thrown */ @@ -50,67 +116,210 @@ public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void } ``` -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. +Use `@And` for complementary preconditions or actions within the same scenario, avoiding +consecutive `@Given` or `@When` tags. + +## Testing exceptions + +Exception classes are value objects describing an invariant violation. They are not the subject +of behavior tests. A test verifies that a public method, under specific conditions, raises a +specific exception. Constructing the exception directly and asserting on its accessors is +prohibited. The exception's structure is exercised through the call path that produces it. + +**Prohibited.** Testing the exception as a value object: + +```php +public function testFromWhenAllFieldsGivenThenExposesEveryAccessor(): void +{ + /** @Given a URL */ + $url = 'https://api.example.com'; + + /** @And an HTTP method */ + $method = Method::GET; + + /** @And a reason */ + $reason = 'Connection refused.'; + + /** @When the exception is constructed */ + $exception = HttpNetworkFailed::from(url: $url, method: $method, reason: $reason); + + /** @Then it exposes the URL */ + self::assertSame($url, $exception->url()); +} +``` + +The test constructs the exception in isolation and asserts on its accessors. No production code +is exercised. The same coverage is achieved (and made meaningful) by the test below, which +drives the path that raises the exception. + +**Correct.** Testing the behavior that raises the exception: + +```php +public function testSendRequestWhenTransportCannotReachServerThenThrowsHttpNetworkFailed(): void +{ + /** @Given an HTTP client backed by a transport that always raises a network error */ + $http = Http::usingTransport(transport: new ThrowingClient()); + + /** @And a target request to that transport */ + $request = Request::create(url: 'https://api.example.com', method: Method::GET); + + /** @Then a network failure exception describing the unreachable target is raised */ + $this->expectException(HttpNetworkFailed::class); + + /** @When the request is sent */ + $http->send(request: $request); +} +``` + +When the accessor values on the raised exception are part of the assertion, `expectException` +alone is not enough (it asserts only the class). Use a `try`/`catch` block as permitted by +rule 14. The catch block contains only assertions against the caught exception, no branching. + +```php +public function testSendRequestWhenTargetUnreachableThenExceptionCarriesUrlAndMethod(): void +{ + /** @Given an HTTP client backed by a transport that always raises a network error */ + $http = Http::usingTransport(transport: new ThrowingClient()); + + /** @And a target request to that transport */ + $request = Request::create(url: 'https://api.example.com', method: Method::GET); + + try { + /** @When the request is sent */ + $http->send(request: $request); + } catch (HttpNetworkFailed $failure) { + /** @Then the exception exposes the target URL and method */ + self::assertSame('https://api.example.com', $failure->url()); + self::assertSame(Method::GET, $failure->method()); + } +} +``` + +If a method does not exist whose call path produces the exception, the exception itself is dead +code. Remove it instead of writing a behavior test against a constructor. + +**The `try`/`catch` form is reserved for assertions that PHPUnit's `expectException*` family +does not cover.** Message, code, and class are covered by PHPUnit (`expectException`, +`expectExceptionMessage`, `expectExceptionMessageMatches`, `expectExceptionCode`): use those +methods, not `try`/`catch`. The only case that warrants `try`/`catch` is inspecting accessors +that PHPUnit cannot reach — notably `getPrevious()` for chain inspection, or domain-specific +accessors on a `TransportFailure` (`url()`, `method()`, `reason()`). + +**Prohibited.** `try`/`catch` to assert message: + +```php +try { + $http->send(request: $request); + self::fail('NoMoreResponses was expected.'); +} catch (NoMoreResponses $exception) { + self::assertStringContainsString('queue exhausted', $exception->getMessage()); +} +``` + +**Correct.** PHPUnit's `expectExceptionMessage`: + +```php +$this->expectException(NoMoreResponses::class); +$this->expectExceptionMessage('queue exhausted'); + +$http->send(request: $request); +``` ## 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 +- Each `@Given` or `@And` block contains exactly one annotation followed by one expression or + assignment. Never place multiple declarations under a single annotation. The exception for + data-provider tests applies here as well (see rule 3). +- No intermediate variables used only once. Chain method calls when the intermediate state is + not referenced elsewhere. +- No private or helper methods in test classes. The only non-test methods allowed are data + providers. Setup logic complex enough to extract belongs in a dedicated fixture class, not in + a private method on the test class. +- Domain terms in variables and properties. 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. +- 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. + +**Prohibited.** Multiple declarations under a single annotation: + +```php +/** @And two money instances in different currencies */ +$usd = Money::of(amount: 500, currency: Currency::USD); +$eur = Money::of(amount: 300, currency: Currency::EUR); +``` + +**Correct.** One annotation per declaration: +```php +/** @And a money instance in USD */ +$usd = Money::of(amount: 500, currency: Currency::USD); + +/** @And a money instance in EUR */ +$eur = Money::of(amount: 300, currency: Currency::EUR); ``` -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 + +**Also prohibited.** Setup multi-statement grouped under a single annotation because "the +statements build one coherent concept": + +```php +/** @Given transport seeded with two responses */ +$first = Response::with(code: Code::OK); +$second = Response::with(code: Code::CREATED); +$transport = InMemoryTransport::with(responses: [$first, $second]); +``` + +Three statements, one annotation. The fact that the three lines together build a single +setup concept is **not** a license to share one annotation. Each declaration takes its own +`@And` block. The same applies under `@When` when the test prepares the input alongside the +action: the input preparation goes back to `@And` under `@Given`, and `@When` contains only +the action under test. + +**Correct.** Each statement keeps its own annotation: + +```php +/** @Given a first queued response */ +$first = Response::with(code: Code::OK); + +/** @And a second queued response */ +$second = Response::with(code: Code::CREATED); + +/** @And transport with both responses */ +$transport = InMemoryTransport::with(responses: [$first, $second]); ``` -`tests/Integration/` is only present when the library interacts with infrastructure. +## Test doubles + +Conventions for naming and locating test doubles (mocks, spies, stubs, fakes, dummies). + +### Naming + +- Variables and properties never carry the technical role in their name. Never `$spy`, `$mock`, + `$stub`, `$fake`, `$dummy`. Use the domain concept the object represents (`$gateway`, + `$clock`, `$repository`, `$client`). +- Class names may carry the technical role as suffix when the class IS a test double + (`ClientMock`, `GatewaySpy`, `ClockFake`). The suffix signals that the file is a collaborator + built for tests, not a production type. + +### Location + +- Test doubles live at the root of `tests/Unit/`. When integration tests exist, doubles used + there live at the root of `tests/Integration/`. +- No dedicated `Mocks/` or `Doubles/` subdirectory exists. +- Domain fixtures that represent real domain concepts live in `tests/Models/`. See + `php-library-architecture.md` for the canonical `tests/` folder layout. + +## Coverage and mutation discipline -## Coverage and mutation testing +- Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from coverage. +- Never suppress mutants via `infection.json.dist` or any other mechanism. +- If a line or mutation cannot be covered or killed, the design is wrong. Refactor the + production code to make it testable. Never work around the tool. -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. +Canonical thresholds (MSI 100, covered MSI 100) live in `php-library-tooling.md`. They are +enforced by `infection.json.dist`. Achieving MSI 100 implies effective full coverage of `src/` +because every mutation must be killed by an assertion. This file covers only the behavioral +rules that complement those thresholds. diff --git a/.claude/rules/php-library-tooling.md b/.claude/rules/php-library-tooling.md new file mode 100644 index 0000000..3b55111 --- /dev/null +++ b/.claude/rules/php-library-tooling.md @@ -0,0 +1,464 @@ +--- +description: Canonical config files for PHP libraries in the tiny-blocks ecosystem. +paths: + - "composer.json" + - "phpcs.xml" + - "phpstan.neon.dist" + - "phpunit.xml" + - "infection.json.dist" + - ".editorconfig" + - ".gitattributes" + - ".gitignore" + - "Makefile" +--- + +# Tooling + +Canonical configuration files for a PHP library in the tiny-blocks ecosystem. Each file has a +fixed shape. Deviations require justification. Folder structure lives in +`php-library-architecture.md`. Code style lives in `php-library-code-style.md`. + +## Pre-output checklist + +Verify every item before creating, editing, or relocating any of the files below. If any item +fails, revise before outputting. + +1. The library repository contains all the following files at its root: `composer.json`, + `phpcs.xml`, `phpstan.neon.dist`, `phpunit.xml`, `infection.json.dist`, `.editorconfig`, + `.gitattributes`, `.gitignore`, `Makefile`. +2. `composer.json` exposes exactly five scripts: `configure`, `configure-and-update`, `review`, + `test-file`, `tests`. No other public scripts are defined. +3. `composer.json` fixed fields use the canonical values defined in the "composer.json" section + (`license`, `type`, `minimum-stability`, `prefer-stable`, `authors`, `config`, `require.php`). +4. `composer.json` `description` is a single short sentence describing what the library does. + Multi-sentence or multi-paragraph descriptions belong in the README Overview, not in Composer + metadata. +5. `composer.json` includes a `keywords` array. The first keyword is always `"tiny-blocks"`. + Additional keywords are topic tokens derived from the library's purpose (`psr-7`, + `http-client`, `event-sourcing`, etc.). +6. `phpcs.xml` references only the `PSR12` ruleset. No additional sniffs are added. +7. `phpunit.xml` sets all five `failOn*` flags to `true`: `failOnDeprecation`, `failOnNotice`, + `failOnPhpunitDeprecation`, `failOnRisky`, `failOnWarning`. +8. `phpunit.xml` sets `executionOrder="random"` and `beStrictAboutOutputDuringTests="true"`. +9. `infection.json.dist` sets `minMsi: 100` and `minCoveredMsi: 100`. Lowering either value is + prohibited. +10. `.editorconfig` sets `max_line_length = 120`, `indent_size = 4`, `indent_style = space`, and + `end_of_line = lf` for PHP files. YAML uses `indent_size = 2`. Makefile uses `indent_style = tab`. +11. `.gitattributes` sets `* text=auto eol=lf` and lists every dev-only file under `export-ignore`. + The Packagist tarball contains only `src/`, `composer.json`, `README.md`, and `LICENSE`. + `.claude/` is listed under `export-ignore` (versioned on GitHub for contributor parity, + excluded from the published package). +12. `.gitignore` follows the canonical content in the ".gitignore" section. `.claude/` is **not** + listed (it is versioned on GitHub). +13. `Makefile` wraps every PHP and Composer command in a Docker container using the canonical + image `gustavofreze/php:8.5-alpine`. No PHP command runs on the host directly. +14. All test artifact paths use `reports/` (plural). The directory is consistent across + `composer tests`, `infection.json.dist`, `phpunit.xml`, and `Makefile`. +15. The `reports/` directory is listed under `export-ignore` in `.gitattributes`. + +## composer.json + +Fixed fields, identical in every library: `license`, `type`, `minimum-stability`, `prefer-stable`, +`require.php`, `authors`, `config.allow-plugins`, `config.sort-packages`, `scripts`, and the five +universal dev dependencies (`ergebnis/composer-normalize`, `infection/infection`, `phpstan/phpstan`, +`phpunit/phpunit`, `squizlabs/php_codesniffer`). + +Per-library fields, vary by library: `name`, `description`, `keywords`, `homepage`, `support`, +`autoload`, `autoload-dev`. The `require-dev` section may add libraries needed by tests (for +example, HTTP client implementations in a PSR-7 library) on top of the five universal tools. + +```json +{ + "name": "tiny-blocks/", + "description": "", + "license": "MIT", + "type": "library", + "keywords": [ + "tiny-blocks", + "", + "" + ], + "authors": [ + { + "name": "Gustavo Freze de Araujo Santos", + "homepage": "https://github.com/gustavofreze" + } + ], + "homepage": "https://github.com/tiny-blocks/", + "support": { + "issues": "https://github.com/tiny-blocks//issues", + "source": "https://github.com/tiny-blocks/" + }, + "require": { + "php": "^8.5" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51", + "infection/infection": "^0.32", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^13.1", + "squizlabs/php_codesniffer": "^4.0" + }, + "minimum-stability": "stable", + "prefer-stable": true, + "autoload": { + "psr-4": { + "TinyBlocks\\\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Test\\TinyBlocks\\\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true, + "infection/extension-installer": true + }, + "sort-packages": true + }, + "scripts": { + "configure": [ + "@composer install --optimize-autoloader", + "@composer normalize" + ], + "configure-and-update": [ + "@composer update --optimize-autoloader", + "@composer normalize" + ], + "review": [ + "@php ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php ./src ./tests", + "@php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress" + ], + "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", + "tests": [ + "@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" + ] + } +} +``` + +Script usage: + +- `composer configure` runs `composer install --optimize-autoloader` followed by `composer normalize`. + Use this after cloning the repository or pulling new changes. +- `composer configure-and-update` runs `composer update --optimize-autoloader` followed by + `composer normalize`. Use this when intentionally updating dependencies. +- `composer review` runs `phpcs` and `phpstan` in sequence. Used by CI and local validation. +- `composer tests` runs `phpunit` followed by `infection`. Used by CI. +- `composer test-file ` runs a filtered subset of tests without coverage. Local + development only. + +## phpcs.xml + +References only the `PSR12` ruleset. Additional formatting rules (vertical alignment, trailing +comma, etc.) live in `php-library-code-style.md` under "Formatting overrides". + +```xml + + + Code style for the tiny-blocks library. + + src + tests + +``` + +## phpstan.neon.dist + +Static analysis configuration. Runs at the highest level on both `src/` and `tests/`. Invoked +by the `review` Composer script. + +```neon +parameters: + level: max + paths: + - src + - tests + reportUnmatchedIgnoredErrors: true +``` + +`ignoreErrors` is permitted to suppress legitimate false positives produced by `level: max` +(third-party type signatures with `mixed`, PHP-FIG interfaces returning untyped arrays, trait +unused-method warnings on shared behavior, etc.). Each entry follows these rules: + +- A short comment above the entry justifies its existence. +- Prefer scoping via `identifier:` plus `path:` over raw `#...#` message patterns. +- `reportUnmatchedIgnoredErrors: true` is mandatory. Obsolete entries fail the build, forcing + cleanup. + +Example with `ignoreErrors`: + +```neon +parameters: + level: max + paths: + - src + - tests + ignoreErrors: + # Trait method intentionally unused by the consuming aggregate; reflection wires it. + - identifier: trait.unused + path: src/Internal/EventualAggregateRootBehavior.php + + # json_encode signature carries `mixed` for backward compatibility at level max. + - identifier: argument.type + path: src/Internal/Serialization/JsonEncoder.php + reportUnmatchedIgnoredErrors: true +``` + +## phpunit.xml + +Strict configuration. All `failOn*` flags are `true`. `executionOrder="random"` forces tests to be +independent of one another. Coverage and JUnit reports go under `reports/`. + +```xml + + + + + + src + + + + + + tests + + + + + + + + + + + + + + + + + +``` + +Root attributes are sorted alphabetically. + +## infection.json.dist + +Mutation testing configuration. `minMsi` and `minCoveredMsi` are both `100`. Mutants that escape +make the build fail. + +```json +{ + "logs": { + "text": "reports/infection/logs/infection-text.log", + "summary": "reports/infection/logs/infection-summary.log" + }, + "tmpDir": "reports/infection/", + "minMsi": 100, + "timeout": 30, + "source": { + "directories": [ + "src" + ] + }, + "phpUnit": { + "configDir": "", + "customPath": "./vendor/bin/phpunit" + }, + "mutators": { + "@default": true + }, + "minCoveredMsi": 100, + "testFramework": "phpunit" +} +``` + +## .editorconfig + +Whitespace and line ending rules applied by editor integrations. + +```ini +root = true + +[*] +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 + +[*.{yml,yaml}] +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false +``` + +## .gitattributes + +Normalizes line endings to LF and excludes every dev-only file from the Packagist tarball. The +published package contains only `src/`, `composer.json`, `README.md`, and `LICENSE`. + +``` +* text=auto eol=lf + +*.php text diff=php + +# 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 +/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 +``` + +## .gitignore + +Keeps the repository working tree clean of artifacts that should never be committed. Entries +are grouped from most fundamental (PHP dependencies) to least critical (OS files). The +`.claude/` directory is **not** listed here. It is versioned on GitHub so other contributors +share the same rules, and it is excluded from the published Packagist tarball through +`export-ignore` in `.gitattributes` (see above). + +``` +# PHP dependencies +/vendor/ +composer.lock + +# Tooling cache +.phpcs-cache +.phpunit.cache/ +.php-cs-fixer.cache +.phpunit.result.cache + +# Coverage and reports +build/ +reports/ +coverage/ +infection.log + +# Editors and agents +.idea/ +.cursor/ +.vscode/ + +# OS +Thumbs.db +.DS_Store +Desktop.ini +``` + +## Makefile + +Thin wrapper over Composer scripts. Every PHP and Composer command runs inside a Docker container +using the canonical image `gustavofreze/php:8.5-alpine`. Targets that match a Composer script +delegate to it directly, avoiding duplication. + +```makefile +PWD := $(CURDIR) +ARCH := $(shell uname -m) +PLATFORM := + +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 + +RESET := \033[0m +GREEN := \033[0;32m +YELLOW := \033[0;33m + +.DEFAULT_GOAL := help + +.PHONY: configure +configure: ## Configure development environment + @${DOCKER_RUN} composer configure + +.PHONY: configure-and-update +configure-and-update: ## Configure development environment and update dependencies + @${DOCKER_RUN} composer configure-and-update + +.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: review +review: ## Run lint and static analysis + @${DOCKER_RUN} composer review + +.PHONY: show-reports +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 + @${DOCKER_RUN} composer outdated --direct + +.PHONY: clean +clean: ## Remove dependencies and generated artifacts + @sudo chown -R ${USER}:${USER} ${PWD} + @rm -rf reports vendor .phpunit.cache *.lock + +.PHONY: help +help: ## Display this help message + @echo "Usage: make [target]" + @echo "" + @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')" + @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 '^(tests|test-file):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')" + @grep -E '^(review):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')" + @grep -E '^(show-reports|show-outdated):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Cleanup$$(printf '$(RESET)')" + @grep -E '^(clean):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' +``` 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..eedb473 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,7 +2,7 @@ *.php text diff=php -# Dev-only — excluded from the Packagist tarball +# Dev-only, excluded from the Packagist tarball /.github export-ignore /tests export-ignore /.claude export-ignore @@ -20,3 +20,5 @@ /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/copilot-instructions.md b/.github/copilot-instructions.md index 77c2bb8..e34c801 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,10 +2,11 @@ ## Context -PHP library (tiny-blocks). Immutable domain models, zero infrastructure dependencies in core. +PHP library in the tiny-blocks ecosystem. ## Mandatory pre-task step -Before starting any task, read and strictly follow all instruction files located in `.claude/CLAUDE.md` and -`.claude/rules/`. These files are the absolute source of truth for code generation. Apply every rule strictly. Do not -deviate from the patterns, folder structure, or naming conventions defined in them. +Before starting any task, read and strictly follow `.claude/CLAUDE.md` and every rule file in +`.claude/rules/`. These files are the absolute source of truth for code generation. Apply every +rule strictly. Do not deviate from the patterns, folder structure, or naming conventions defined +in them. 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/ci.yml b/.github/workflows/ci.yml index 71e59e0..d395d35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,26 +3,44 @@ name: CI on: pull_request: +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: contents: read -env: - PHP_VERSION: '8.5' - jobs: + resolve-php-version: + name: Resolve PHP version + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + php-version: ${{ steps.config.outputs.php-version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Resolve PHP version from composer.json + id: config + run: | + version=$(jq -r '.require.php' composer.json | grep -oP '\d+\.\d+' | head -1) + echo "php-version=$version" >> "$GITHUB_OUTPUT" + build: name: Build + needs: resolve-php-version runs-on: ubuntu-latest - + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 - - name: Configure PHP + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_VERSION }} tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} - name: Validate composer.json run: composer validate --no-interaction @@ -40,18 +58,18 @@ jobs: auto-review: name: Auto review + needs: [resolve-php-version, build] runs-on: ubuntu-latest - needs: build - + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 - - name: Configure PHP + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_VERSION }} tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} - name: Download vendor artifact from build uses: actions/download-artifact@v8 @@ -64,18 +82,18 @@ jobs: tests: name: Tests + needs: [resolve-php-version, auto-review] runs-on: ubuntu-latest - needs: auto-review - + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 - - name: Configure PHP + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_VERSION }} tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} - name: Download vendor artifact from build uses: actions/download-artifact@v8 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4c6d7f7..0634bbf 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,6 +8,10 @@ on: schedule: - cron: "0 0 * * *" +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + permissions: actions: read contents: read @@ -17,11 +21,11 @@ jobs: analyze: name: Analyze runs-on: ubuntu-latest + timeout-minutes: 30 strategy: fail-fast: false matrix: language: [ "actions" ] - steps: - name: Checkout repository uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index bd5baa3..6107765 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,25 @@ -# Agent/IDE -.claude/ -.idea/ -.vscode/ -.cursor/ - -# Composer +# PHP dependencies /vendor/ composer.lock -# PHPUnit / coverage +# Tooling cache +.phpcs-cache .phpunit.cache/ +.php-cs-fixer.cache .phpunit.result.cache -report/ -coverage/ + +# Coverage and reports build/ +reports/ +coverage/ +infection.log + +# Editors and agents +.idea/ +.cursor/ +.vscode/ # OS -.DS_Store Thumbs.db +.DS_Store +Desktop.ini diff --git a/Makefile b/Makefile index 07acc3b..4f0e85d 100644 --- a/Makefile +++ b/Makefile @@ -16,28 +16,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: test -test: ## Run all tests with coverage +.PHONY: configure-and-update +configure-and-update: ## Configure development environment and update dependencies + @${DOCKER_RUN} composer configure-and-update + +.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 +45,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 5916f07..a097649 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,41 @@ # Http -[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/tiny-blocks/http/blob/main/LICENSE) * [Overview](#overview) * [Installation](#installation) * [How to use](#how-to-use) - * [Request](#request) - * [Response](#response) + + [Server](#server) + - [Decoding a request](#decoding-a-request) + - [Creating a response](#creating-a-response) + - [Setting cookies](#setting-cookies) + - [Status code](#status-code) + + [Client](#client) + - [Building Http with a PSR-18 client and PSR-17 factories](#building-http-with-a-psr-18-client-and-psr-17-factories) + - [Making a request](#making-a-request) + - [Reading the response](#reading-the-response) + - [Query parameters](#query-parameters) + - [Custom headers and content type](#custom-headers-and-content-type) + - [Setting the User-Agent](#setting-the-user-agent) + - [Error handling](#error-handling) + - [Configuring timeouts](#configuring-timeouts) + - [Testing with InMemoryTransport](#testing-with-inmemorytransport) + - [Extending with custom transports](#extending-with-custom-transports) +* [FAQ](#faq) * [License](#license) * [Contributing](#contributing) -
    - ## Overview -Implements [PSR-7](https://www.php-fig.org/psr/psr-7) and [PSR-15](https://www.php-fig.org/psr/psr-15) HTTP primitives -for PHP, covering requests, responses, streams, cookies, headers, methods, status codes, and cache-control directives. -Ships with a fluent response builder that maps common outcomes to the correct HTTP semantics out of the box. -Interoperable with Slim, Laminas, and any PSR-compliant framework. +The library covers both sides of an HTTP exchange: + +- **Server side** (`TinyBlocks\Http\Server`) - decodes a PSR-7 `ServerRequestInterface` into typed accessors and builds + outgoing `ResponseInterface` instances with cookies, cache-control, and status codes. +- **Client side** (`TinyBlocks\Http\Client`) - composes outbound requests, sends them through a `Transport` port backed + by any PSR-18 client, and exposes responses with typed body and header access. -
    +Shared primitives at `TinyBlocks\Http\`: `Method`, `Code`, `Headers`, `Headerable`, `ContentType`, `MimeType`, +`Charset`, `Cookie`, `SameSite`, `CacheControl`, `ResponseCacheDirectives`, `UserAgent`. ## Installation @@ -27,373 +43,615 @@ Interoperable with Slim, Laminas, and any PSR-compliant framework. composer require tiny-blocks/http ``` -
    - ## How to use -The library exposes interfaces like `Headers` and concrete implementations like `Request`, `Response`, `ContentType`, -and others, which facilitate construction. +### Server -
    +#### Decoding a request -### Request +Wrap a PSR-7 `ServerRequestInterface` and read typed fields from the body, route parameters, and query string. -#### Decoding a request +```php +decode(); + +$id = $decoded->uri()->route()->get(key: 'id')->toInteger(); +$sort = $decoded->uri()->queryParameters()->get(key: 'sort')->toString(); +$name = $decoded->body()->get(key: 'name')->toString(); +$amount = $decoded->body()->get(key: 'amount')->toFloat(); +``` -The library provides a small public API to decode a PSR-7 `ServerRequestInterface` into a typed structure, allowing you -to access route parameters and JSON body fields consistently. +The HTTP method is available as a typed `Method` enum: + +```php +method(); +``` + +#### Creating a response - /** @var ServerRequestInterface $psrRequest */ - $decoded = Request::from(request: $psrRequest)->decode(); +Each helper returns a PSR-7 `ResponseInterface` and defaults to `application/json`: - $name = $decoded->body()->get(key: 'name')->toString(); - $payload = $decoded->body()->toArray(); +```php +uri()->route()->get(key: 'id')->toInteger(); - ``` +declare(strict_types=1); -- **Access the HTTP method**: Use `method()` directly on the `Request` to retrieve the HTTP verb as a typed `Method` - enum. +use TinyBlocks\Http\Server\Response; - ```php - use Psr\Http\Message\ServerRequestInterface; - use TinyBlocks\Http\Request; +Response::ok(body: ['message' => 'Resource created successfully.']); +Response::created(body: ['id' => 42]); +Response::noContent(); +Response::notFound(body: ['error' => 'Resource not found.']); +``` - /** @var ServerRequestInterface $psrRequest */ - $request = Request::from(request: $psrRequest); +For custom status codes, use `from(...)`: - $method = $request->method(); # Method::POST - $method->value; # "POST" - ``` +```php +decode(); +Response::from(body: ['status' => 'accepted'], code: Code::ACCEPTED); +``` - $fullUri = $decoded->uri()->toString(); # "https://api.example.com/v1/dragons?sort=name" - ``` +Attach additional headers via varargs of `Headerable`: -- **Access query parameters**: Use `queryParameters()` on the decoded `uri()` to retrieve typed access to query string - values. Each value is returned as an `Attribute`, providing the same safe conversions and defaults as body fields. +```php +decode(); +use TinyBlocks\Http\CacheControl; +use TinyBlocks\Http\ContentType; +use TinyBlocks\Http\ResponseCacheDirectives; +use TinyBlocks\Http\Server\Response; - $queryParams = $decoded->uri()->queryParameters()->toArray(); # ['sort' => 'name', 'limit' => '50'] - $sort = $decoded->uri()->queryParameters()->get(key: 'sort')->toString(); # "name" - $limit = $decoded->uri()->queryParameters()->get(key: 'limit')->toInteger(); # 50 - $active = $decoded->uri()->queryParameters()->get(key: 'active')->toBoolean(); # default: false - ``` +$cacheControl = CacheControl::fromResponseDirectives( + ResponseCacheDirectives::maxAge(maxAgeInWholeSeconds: 10000) +); -- **Typed access with defaults**: Each value is returned as an Attribute, which provides safe conversions and default - values when the underlying value is missing or not compatible. +Response::ok(['ok' => true], $cacheControl, ContentType::applicationJson()) + ->withHeader(name: 'X-Trace-Id', value: 'abc-123'); +``` - ```php - use TinyBlocks\Http\Request; - - $request = Request::from(request: $psrRequest); - $decoded = $request->decode(); - - $method = $request->method(); # default: Method enum - - $id = $decoded->uri()->route()->get(key: 'id')->toInteger(); # default: 0 - $uri = $decoded->uri()->toString(); # default: "" - $sort = $decoded->uri()->queryParameters()->get(key: 'sort')->toString(); # default: "" - $limit = $decoded->uri()->queryParameters()->get(key: 'limit')->toInteger(); # default: 0 - - $note = $decoded->body()->get(key: 'note')->toString(); # default: "" - $tags = $decoded->body()->get(key: 'tags')->toArray(); # default: [] - $price = $decoded->body()->get(key: 'price')->toFloat(); # default: 0.00 - $active = $decoded->body()->get(key: 'active')->toBoolean(); # default: false - ``` +#### Setting cookies -- **Custom route attribute name**: If your framework stores route params in a different request attribute, you can - specify it via `route()`. +`Cookie` implements `Headerable` and composes naturally with `Response`: - ```php - use TinyBlocks\Http\Request; - - $decoded = Request::from(request: $psrRequest)->decode(); +```php +uri()->route(name: '_route_params')->get(key: 'id')->toInteger(); - ``` +declare(strict_types=1); -#### How route parameters are resolved +use TinyBlocks\Http\Cookie; +use TinyBlocks\Http\SameSite; +use TinyBlocks\Http\Server\Response; -The library resolves route parameters from the PSR-7 `ServerRequestInterface` using a **multistep fallback strategy**, -designed to work across different frameworks without importing any framework-specific code. +$session = Cookie::create(name: 'session', value: $token) + ->secure() + ->httpOnly() + ->withPath(path: '/v1/sessions') + ->withMaxAge(seconds: 604800) + ->withSameSite(sameSite: SameSite::STRICT); -**Resolution order** (when using the default `route()` or `route(name: '...')`): +Response::ok(['ok' => true], $session); +``` -1. **Specified attribute lookup** — Reads the attribute from the request using the configured name (default: - `__route__`). - - If the value is an **array**, the key is looked up directly. - - If the value is an **object**, the resolver tries known accessor methods (`getArguments()`, - `getMatchedParams()`, `getParameters()`, `getParams()`) and then public properties (`arguments`, `params`, - `vars`, `parameters`). - - If the value is a **scalar** (e.g., a string), it is returned as-is. +To expire a cookie, use `Cookie::expire(...)` with the same `Path` and `Domain` used at creation. -2. **Known attribute scan** (only when using the default `__route__` name) — Scans all commonly used attribute keys - across frameworks: - - `__route__`, `_route_params`, `route`, `routing`, `routeResult`, `routeInfo` +```php +getAttribute($key)` directly, which supports - frameworks like Laravel that store route params as individual request attributes. +declare(strict_types=1); -4. **Safe default** — If nothing is found, returns `Attribute::from(null)`, which provides safe conversions: - `toInteger()` → `0`, `toString()` → `""`, `toFloat()` → `0.00`, `toBoolean()` → `false`, `toArray()` → `[]`. +use TinyBlocks\Http\Cookie; +use TinyBlocks\Http\SameSite; +use TinyBlocks\Http\Server\Response; -**Supported frameworks and attribute formats:** +$expired = Cookie::expire(name: 'session') + ->secure() + ->httpOnly() + ->withPath(path: '/v1/sessions') + ->withSameSite(sameSite: SameSite::STRICT); -| Framework | Attribute Key | Format | -|-------------------------|-----------------|-----------------------------------------------| -| **Slim 4** | `__route__` | Object with `getArguments()` | -| **Mezzio / Expressive** | `routeResult` | Object with `getMatchedParams()` | -| **Symfony** | `_route_params` | `array` | -| **Laravel** | *(direct)* | `getAttribute('id')` directly on the request | -| **FastRoute (generic)** | `routeInfo` | Array with route parameters | -| **Manual injection** | Any custom key | `$request->withAttribute('__route__', [...])` | +Response::noContent($expired); +``` -#### Manually injecting route parameters +#### Status code -If your framework or middleware does not automatically populate route attributes, you can inject them manually using -PSR-7's `withAttribute()`: +The `Code` enum carries the full RFC HTTP status set with typed helpers: ```php -use TinyBlocks\Http\Request; +withAttribute('__route__', [ - 'id' => '42', - 'email' => 'user@example.com' -]); +declare(strict_types=1); -$decoded = Request::from(request: $psrRequest)->decode(); -$id = $decoded->uri()->route()->get(key: 'id')->toInteger(); # 42 - -$psrRequest = $psrRequest->withAttribute('my_params', ['slug' => 'hello-world']); -$slug = Request::from(request: $psrRequest) - ->decode() - ->uri() - ->route(name: 'my_params') - ->get(key: 'slug') - ->toString(); # "hello-world" +use TinyBlocks\Http\Code; + +Code::OK->value; # 200 +Code::OK->message(); # "OK" +Code::OK->isSuccess(); # true +Code::INTERNAL_SERVER_ERROR->isError(); # true + +Code::isValidCode(code: 200); # true +Code::isErrorCode(code: 500); # true +Code::isSuccessCode(code: 200); # true ``` -
    +### Client -### Response +#### Building Http with a PSR-18 client and PSR-17 factories -#### Creating a response +Assemble the facade with any PSR-18 client and PSR-17 factories. + +```php + 'Resource created successfully.']); - ``` - -- **Creating a response with a body and custom headers**: You can also add custom headers to the response. For instance, - if you want to specify a custom content type or any other header, you can pass the headers as additional arguments. - - ```php - use TinyBlocks\Http\Response; - use TinyBlocks\Http\ContentType; - use TinyBlocks\Http\CacheControl; - use TinyBlocks\Http\ResponseCacheDirectives; - - $body = 'This is a plain text response'; - - $contentType = ContentType::textPlain(); - - $cacheControl = CacheControl::fromResponseDirectives( - maxAge: ResponseCacheDirectives::maxAge(maxAgeInWholeSeconds: 10000), - staleIfError: ResponseCacheDirectives::staleIfError() - ); - - Response::ok($body, $contentType, $cacheControl) - ->withHeader(name: 'X-ID', value: 100) - ->withHeader(name: 'X-NAME', value: 'Xpto'); - ``` +declare(strict_types=1); -#### Setting cookies +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\HttpFactory; +use TinyBlocks\Http\Client\Transports\NetworkTransport; +use TinyBlocks\Http\Http; + +$factory = new HttpFactory(); +$client = new Client(config: ['timeout' => 30, 'connect_timeout' => 5]); + +$http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with(client: $client, factory: $factory)) + ->build(); +``` + +For a single-call construction without the fluent builder: + +```php + 30, 'connect_timeout' => 5]), + factory: $factory + ) +); +``` + +#### Making a request + +Every parameter on `Request::create(...)` is explicit. Pass `null` for `body` and `query` when absent. Pass +`Method::GET` (or another method) for `method`. Build `headers` from one or more `Headerable` instances via +`Headers::from(...)`, or call `Headers::from()` with no arguments when no headers apply. + +```php +send( + request: Request::create( + url: '/v1/charges', + body: ['amount' => 1000, 'currency' => 'usd'], + query: null, + method: Method::POST, + headers: Headers::from(ContentType::applicationJson()) + ) +); +``` + +A simple `GET` still passes every parameter, with `Headers::from()` for the empty header set: + +```php +send( + request: Request::create( + url: '/v1/charges/abc123', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ) +); +``` + +#### Reading the response + +```php +isSuccess()) { + $id = $response->body()->get(key: 'id')->toString(); + $amount = $response->body()->get(key: 'amount')->toInteger(); +} + +$response->raw(); # Psr\Http\Message\ResponseInterface +$response->code(); # Code enum +$response->headers(); # TinyBlocks\Http\Headers value object +``` + +`Headers` exposes case-insensitive lookup: + +```php +$contentType = $response->headers()->get(name: 'content-type'); # "application/json" +$hasTrace = $response->headers()->has(name: 'X-Trace-Id'); # true +``` + +#### Query parameters + +Pass the query as a named parameter - the library encodes it in RFC3986 form. + +```php +send( + request: Request::create( + url: '/v1/charges', + body: null, + query: ['status' => 'succeeded', 'limit' => 50], + method: Method::GET, + headers: Headers::from() + ) +); +``` + +#### Custom headers and content type + +Compose any combination of `Headerable` via `Headers::from(...)`: + +```php + $this->value]; + } +} + +$response = $http->send( + request: Request::create( + url: '/v1/charges', + body: ['amount' => 1000], + query: null, + method: Method::POST, + headers: Headers::from( + ContentType::applicationJson(), + new IdempotencyKey(value: $key) + ) + ) +); +``` + +Custom headers always win over the library's JSON defaults. + +#### Setting the User-Agent + +The `UserAgent` value object implements `Headerable` and renders the standard +`User-Agent` header. Empty version is normalized to "no version" - the rendered +header carries only the product token in that case, so configuration with an +optional version flows in directly. + +```php +send( + request: Request::create( + url: '/v1/charges', + body: null, + query: null, + method: Method::GET, + headers: Headers::from($userAgent) + ) +); +``` + +When the version is unknown: + +```php +send( + request: Request::create( + url: '/v1/charges', + body: ['amount' => 1000], + query: null, + method: Method::POST, + headers: Headers::from( + UserAgent::from(product: 'MyApp', version: '1.2.3'), + ContentType::applicationJson() + ) + ) +); +``` + +#### Error handling + +Every failure raises an `HttpException`. `TransportFailure` (which extends `HttpException`) carries `url()`, +`method()`, and `reason()`, and is implemented by every exception raised by the transport layer. The remaining +`HttpException` implementations carry only the marker contract. Inspect their concrete class for the invariant +they violated. Catch the specific class when you need to react to a particular failure mode. Order of catch +branches matters because PHP matches the first applicable branch. + +```php +send(request: $request); +} catch (HttpRequestInvalid $exception) { + # PSR-18 RequestExceptionInterface: request malformed before transport. + echo $exception->url(); + echo $exception->method()->name; + echo $exception->reason(); +} catch (TransportFailure $exception) { + # Other transport failures (network errors, generic PSR-18 client failures). + echo $exception->url(); + echo $exception->method()->name; + echo $exception->reason(); +} catch (HttpException $exception) { + # Library-level failures (configuration, malformed path, exhausted in-memory transport). + echo $exception::class; +} +``` + +| Exception | Cause | +|-------------------------------|---------------------------------------------------------------------------------------| +| `HttpRequestFailed` | Generic PSR-18 `ClientExceptionInterface`. | +| `HttpNetworkFailed` | PSR-18 `NetworkExceptionInterface` - DNS, timeout, connection refused. | +| `HttpRequestInvalid` | PSR-18 `RequestExceptionInterface` - request malformed before transport. | +| `MalformedPath` | Path attempts to escape the base URL (scheme, protocol-relative, control characters). | +| `NoMoreResponses` | `InMemoryTransport` exhausted (programmer error). | +| `HttpConfigurationInvalid` | Builder called without required dependencies. | +| `SynthesizedResponseHasNoRaw` | `Response::raw()` called on a response created via `Response::with(...)`. | + +#### Configuring timeouts -The library models the `Set-Cookie` HTTP response header through the `Cookie` value object, covering the full -[RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265) attribute set plus modern additions such as `SameSite` and -`Partitioned`. Instances are immutable and fluent — every builder call returns a new `Cookie`. Like `ContentType` and -`CacheControl`, `Cookie` implements `Headers`, so it composes naturally with any `Response` factory via varargs. - -- **Setting a session cookie**: Build a cookie with the required security flags and attach it to a response. - - ```php - use TinyBlocks\Http\Cookie; - use TinyBlocks\Http\Response; - use TinyBlocks\Http\SameSite; - - $cookie = Cookie::create(name: 'refresh_token', value: $opaqueToken) - ->httpOnly() - ->secure() - ->withSameSite(sameSite: SameSite::STRICT) - ->withPath(path: '/v1/sessions') - ->withMaxAge(seconds: 604800); - - Response::ok(body: ['ok' => true], $cookie); - ``` - -- **Setting multiple cookies**: Pass each `Cookie` as an additional header argument. The response emits one - `Set-Cookie` header per cookie, preserving all of them (this follows the PSR-7 multi-value header model). - - ```php - use TinyBlocks\Http\Cookie; - use TinyBlocks\Http\Response; - use TinyBlocks\Http\SameSite; - - $accessCookie = Cookie::create(name: 'access_token', value: $accessToken) - ->httpOnly() - ->secure() - ->withPath(path: '/'); - - $refreshCookie = Cookie::create(name: 'refresh_token', value: $refreshToken) - ->httpOnly() - ->secure() - ->withSameSite(sameSite: SameSite::STRICT) - ->withPath(path: '/v1/sessions') - ->withMaxAge(seconds: 604800); - - Response::ok(body: ['ok' => true], $accessCookie, $refreshCookie); - ``` - -- **Expiring a cookie**: Use `Cookie::expire()` to instruct the browser to delete a previously set cookie. Chain the - same `Path` (and `Domain`, if applicable) used when the cookie was issued; otherwise the browser will not match the - cookie and the deletion will have no effect. - - ```php - use TinyBlocks\Http\Cookie; - use TinyBlocks\Http\Response; - use TinyBlocks\Http\SameSite; - - $expired = Cookie::expire(name: 'refresh_token') - ->httpOnly() - ->secure() - ->withSameSite(sameSite: SameSite::STRICT) - ->withPath(path: '/v1/sessions'); - - Response::noContent($expired); - ``` - -- **Using an absolute expiration date**: When an explicit deletion moment is preferable over `Max-Age`, use - `withExpires()`. The date is converted to UTC and rendered in - the [RFC 7231](https://datatracker.ietf.org/doc/html/rfc7231) - date format required by the `Expires` attribute. `Max-Age` and `Expires` are mutually exclusive — setting both - throws `ConflictingLifetimeAttributes` when the response is serialized. - - ```php - use DateTimeImmutable; - use DateTimeZone; - use TinyBlocks\Http\Cookie; - - Cookie::create(name: 'preference', value: 'dark-mode')->withExpires( - expires: new DateTimeImmutable(datetime: '2030-01-15 12:00:00', timezone: new DateTimeZone(timezone: 'UTC')) - ); - ``` - -- **Cross-site cookies**: `SameSite::NONE` requires the `Secure` flag — modern browsers reject `SameSite=None` cookies - sent over insecure connections. The library enforces this invariant at serialization time and throws - `SameSiteNoneRequiresSecure` when the combination is incomplete. - - ```php - use TinyBlocks\Http\Cookie; - use TinyBlocks\Http\SameSite; - - Cookie::create(name: 'embed_session', value: $token) - ->withSameSite(sameSite: SameSite::NONE) - ->secure(); - ``` - -- **Validation at construction time**: Cookie names and values are validated against - [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265). Names cannot be empty nor contain control characters, - whitespace, or token separators (``( ) < > @ , ; : \ " / [ ] ? = { }``). Values cannot contain control characters, - whitespace, double quotes, commas, semicolons, or backslashes. Encode the value (e.g., URL-encode or Base64) before - passing it when it may contain arbitrary text. - - ```php - use TinyBlocks\Http\Cookie; - - Cookie::create(name: 'user_id', value: (string)$userId); # valid - Cookie::create(name: 'payload', value: base64_encode($jsonBody)); # encode arbitrary values first - ``` - -#### Using the status code - -The library exposes a concrete implementation through the `Code` enum. You can retrieve the status codes, their -corresponding messages, and check for various status code ranges using the methods provided. - -- **Get message**: Returns the [HTTP status message](https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages) - associated with the enum's code. - - ```php - use TinyBlocks\Http\Code; - - Code::OK->value; # 200 - Code::OK->message(); # OK - Code::IM_A_TEAPOT->message(); # I'm a teapot - Code::INTERNAL_SERVER_ERROR->message(); # Internal Server Error - ``` - -- **Check if the code is valid**: Determines if the given code is a valid HTTP status code represented by the enum. - - ```php - use TinyBlocks\Http\Code; - - Code::isValidCode(code: 200); # true - Code::isValidCode(code: 999); # false - ``` - -- **Check if the code is an error**: Determines if the given code is in the error range (**4xx** or **5xx**). - - ```php - use TinyBlocks\Http\Code; - - Code::isErrorCode(code: 500); # true - Code::isErrorCode(code: 200); # false - ``` - -- **Check if the code is a success**: Determines if the given code is in the success range (**2xx**). - - ```php - use TinyBlocks\Http\Code; - - Code::isSuccessCode(code: 500); # false - Code::isSuccessCode(code: 200); # true - ``` - -
    +PSR-18 does not standardize timeouts. Configure them on the underlying client before injection. + +**Guzzle:** + +```php + 30, 'connect_timeout' => 5]); +``` + +**Symfony HttpClient:** + +```php + 30])); +``` + +#### Testing with InMemoryTransport + +Pre-program responses with `Response::with(...)` and feed them to `InMemoryTransport`: + +```php + 'ch_abc123']), + Response::with(code: Code::OK, body: ['status' => 'paid']) + ] +); + +$http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: $transport) + ->build(); +``` + +Calls consume responses in FIFO order. Exhaustion raises `NoMoreResponses`. + +#### Extending with custom transports + +Implement `Transport` to add retry, logging, circuit breaker, or any other cross-cutting concern. The decorator wraps +any inner `Transport`. + +```php +inner->send(request: $request); + } catch (HttpNetworkFailed $exception) { + $attempt++; + + if ($attempt >= $this->maxAttempts) { + throw $exception; + } + } + } + } +} +``` + +Compose it into the facade: + +```php +withBaseUrl(url: 'https://api.example.com') + ->withTransport( + transport: new RetryingTransport( + inner: NetworkTransport::with(client: $client, factory: $factory), + maxAttempts: 3 + ) + ) + ->build(); +``` + +## FAQ + +### 01. Why is there a `Headerable` interface and a `Headers` value object? + +`Headerable` is the contract implemented by classes that emit one or more header lines such as `ContentType`, `Cookie`, +`CacheControl`, and any custom header type. `Headers` is the value object that carries the consolidated header set of an +HTTP request or response, with case-insensitive lookup and merging. + +### 02. Why are timeouts not part of the public API? + +PSR-18 does not standardize timeouts. Exposing them in the facade would require a transport-specific contract that leaks +the underlying client. Configure timeouts on the PSR-18 client before injecting it. + +### 03. Why does `Response::raw()` throw on a synthesized response? + +A response created via `Response::with(...)` has no PSR-7 backing - it exists only for in-process scenarios (tests, +`InMemoryTransport`). Calling `raw()` in that mode is a programmer error and raises `SynthesizedResponseHasNoRaw`. + +### 04. Why is path validation enforced at the resolver? + +To protect the configured base URL from being hijacked by paths that contain a scheme, are protocol-relative, or carry +control characters. Such inputs raise `MalformedPath` before the transport is invoked. + +### 05. What happens to status codes outside the `Code` enum? + +`Response::from()` requires a code present in the enum, which covers every RFC code in use. Non-RFC status codes are +reachable through `Response::raw()->getStatusCode()`. ## License Http is licensed under [MIT](LICENSE). -
    - ## Contributing Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1001558 --- /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/http/security/advisories/new). + +Please do not disclose the vulnerability publicly until it has been addressed. diff --git a/composer.json b/composer.json index a410ac1..9ff38d2 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,17 @@ { "name": "tiny-blocks/http", - "description": "Implements PSR-7 and PSR-15 HTTP primitives for PHP, with a fluent response builder, cookies, and cache control.\n\nThe package is designed to be used in any PHP application, and can be used as a standalone library or as part of a larger framework.", + "description": "Implements PSR-7, PSR-15, and PSR-18 HTTP primitives for PHP, with a fluent response builder, cookies, cache control, and a PSR-18 client facade.", "license": "MIT", "type": "library", + "keywords": [ + "http", + "psr-7", + "psr-15", + "psr-18", + "http-codes", + "tiny-blocks", + "http-client" + ], "authors": [ { "name": "Gustavo Freze de Araujo Santos", @@ -16,15 +25,21 @@ }, "require": { "php": "^8.5", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.1", "psr/http-message": "^2.0", - "tiny-blocks/mapper": "^2.0" + "tiny-blocks/mapper": "^2.1" }, "require-dev": { "ergebnis/composer-normalize": "^2.51", + "guzzlehttp/guzzle": "^7.9", "infection/infection": "^0.32", "laminas/laminas-httphandlerrunner": "^2.13", + "nyholm/psr7": "^1.8", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^13.1", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", "slim/psr7": "^1.8", "slim/slim": "^4.15", "squizlabs/php_codesniffer": "^4.0" @@ -49,22 +64,22 @@ "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..a52372c --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,7 @@ + + + Code style for the tiny-blocks library. + + src + tests + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b06e4e1..1e394cf 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,12 +1,18 @@ parameters: - paths: - - src - level: 9 - tmpDir: report/phpstan - ignoreErrors: - - '#expects#' - - '#should return#' - - '#mixed to string#' - - '#does not accept#' - - '#type specified in iterable type#' - reportUnmatchedIgnoredErrors: false + level: max + paths: + - src + - tests + tmpDir: reports/phpstan + ignoreErrors: + - identifier: return.type + - identifier: argument.type + - identifier: missingType.iterableValue + - identifier: offsetAccess.nonOffsetAccessible + # PHPDoc is prohibited inside tests/; generic IteratorAggregate types are unspecified by design. + - identifier: missingType.generics + path: tests/Models/Products.php + # PHPDoc is prohibited inside tests/; the closure's typed return cannot be expressed. + - identifier: throw.notThrowable + path: tests/Unit/FailingTransport.php + reportUnmatchedIgnoredErrors: true 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/Internal/Request/Attribute.php b/src/Attribute.php similarity index 52% rename from src/Internal/Request/Attribute.php rename to src/Attribute.php index c8334b1..47da7a3 100644 --- a/src/Internal/Request/Attribute.php +++ b/src/Attribute.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Request; +namespace TinyBlocks\Http; final readonly class Attribute { @@ -10,11 +10,22 @@ private function __construct(private mixed $value) { } + /** + * Creates an Attribute wrapping the given value. + * + * @param mixed $value The value carried by the Attribute. + * @return Attribute An Attribute wrapping the supplied value. + */ public static function from(mixed $value): Attribute { return new Attribute(value: $value); } + /** + * Returns the Attribute as an array. + * + * @return array The wrapped value when it is an array, otherwise an empty array. + */ public function toArray(): array { return match (true) { @@ -23,6 +34,11 @@ public function toArray(): array }; } + /** + * Returns the Attribute as a float. + * + * @return float The wrapped value coerced to a float, or 0.00 when it is not scalar. + */ public function toFloat(): float { return match (true) { @@ -31,6 +47,11 @@ public function toFloat(): float }; } + /** + * Returns the Attribute as a string. + * + * @return string The wrapped value coerced to a string, or an empty string when it is not scalar. + */ public function toString(): string { return match (true) { @@ -39,6 +60,11 @@ public function toString(): string }; } + /** + * Returns the Attribute as an integer. + * + * @return int The wrapped value coerced to an integer, or 0 when it is not scalar. + */ public function toInteger(): int { return match (true) { @@ -47,6 +73,11 @@ public function toInteger(): int }; } + /** + * Returns the Attribute as a boolean. + * + * @return bool The wrapped value coerced to a boolean, or false when it is not scalar. + */ public function toBoolean(): bool { return match (true) { diff --git a/src/Body.php b/src/Body.php new file mode 100644 index 0000000..2d5dc36 --- /dev/null +++ b/src/Body.php @@ -0,0 +1,108 @@ + $data The decoded body data. + * @return Body A Body wrapping the supplied data. + */ + public static function fromArray(array $data): Body + { + return new Body(data: $data); + } + + /** + * Creates a Body from a PSR-7 server request, parsing JSON or falling back to the parsed body. + * + * @param ServerRequestInterface $request The incoming PSR-7 server request. + * @return Body A Body carrying the decoded request payload. + */ + public static function fromServerRequest(ServerRequestInterface $request): Body + { + $streamFactory = StreamFactory::fromStream(stream: $request->getBody()); + + if (!$streamFactory->isEmptyContent()) { + $decoded = json_decode($streamFactory->content(), true); + + return new Body(data: is_array($decoded) ? $decoded : []); + } + + $parsedBody = $request->getParsedBody(); + + return new Body(data: is_array($parsedBody) ? $parsedBody : []); + } + + /** + * Creates a Body from a PSR-7 response, decoding the JSON payload and degrading to empty on failure. + * + * @param ResponseInterface $response The PSR-7 response whose body is decoded. + * @return Body A Body carrying the decoded payload, or an empty Body when decoding fails. + */ + public static function fromResponse(ResponseInterface $response): Body + { + $stream = $response->getBody(); + + if ($stream->isSeekable()) { + $stream->rewind(); + } + + $raw = $stream->getContents(); + + if ($stream->isSeekable()) { + $stream->rewind(); + } + + try { + $decoded = json_decode( + $raw, + true, + Body::MAX_JSON_DEPTH, + JSON_THROW_ON_ERROR + ); + } catch (JsonException) { + return new Body(data: []); + } + + return new Body(data: is_array($decoded) ? $decoded : []); + } + + /** + * Returns the Body as an associative array. + * + * @return array The decoded body data. + */ + public function toArray(): array + { + return $this->data; + } + + /** + * Returns the Attribute associated with the given key. + * + * @param string $key The key to look up in the body. + * @return Attribute The Attribute wrapping the value, or wrapping null when absent. + */ + public function get(string $key): Attribute + { + $attributeValue = ($this->data[$key] ?? null); + + return Attribute::from(value: $attributeValue); + } +} diff --git a/src/CacheControl.php b/src/CacheControl.php index bb8e53c..f1ea89c 100644 --- a/src/CacheControl.php +++ b/src/CacheControl.php @@ -4,22 +4,27 @@ namespace TinyBlocks\Http; -/** - * Defines HTTP Cache-Control headers and their directives. - * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control - */ -final readonly class CacheControl implements Headers +final readonly class CacheControl implements Headerable { private function __construct(private array $directives) { } + /** + * Creates a CacheControl from a list of response directives. + * + * @param ResponseCacheDirectives ...$directives The directives folded into the Cache-Control header. + * @return CacheControl A header carrying every supplied directive in the given order. + */ public static function fromResponseDirectives(ResponseCacheDirectives ...$directives): CacheControl { - $mapper = fn(ResponseCacheDirectives $directive) => $directive->toString(); + $values = []; - return new CacheControl(directives: array_map($mapper, $directives)); + foreach ($directives as $directive) { + $values[] = $directive->toString(); + } + + return new CacheControl(directives: $values); } public function toArray(): array diff --git a/src/Charset.php b/src/Charset.php index 67faa2a..2b660aa 100644 --- a/src/Charset.php +++ b/src/Charset.php @@ -17,8 +17,15 @@ enum Charset: string case ISO_8859_1 = 'iso-8859-1'; case WINDOWS_1252 = 'windows-1252'; + /** + * Returns the Charset as a Content-Type charset parameter. + * + * @return string The header fragment in the form charset={value}. + */ public function toString(): string { - return sprintf('charset=%s', $this->value); + $template = 'charset=%s'; + + return sprintf($template, $this->value); } } diff --git a/src/Client/Request.php b/src/Client/Request.php new file mode 100644 index 0000000..97e0e03 --- /dev/null +++ b/src/Client/Request.php @@ -0,0 +1,141 @@ +|null $body The request body as an associative array, or null when absent. + * @param array|null $query The query string parameters, or null when absent. + * @param Method $method The HTTP method used by the request. + * @param Headers $headers The headers folded into the request. + * @return Request A new immutable request instance. + */ + public static function create( + string $url, + ?array $body, + ?array $query, + Method $method, + Headers $headers + ): Request { + return new Request(url: $url, body: $body, query: $query, method: $method, headers: $headers); + } + + /** + * Returns the url. + * + * @return string The URL the request targets. + */ + public function url(): string + { + return $this->url; + } + + /** + * Returns the body. + * + * @return array|null The request body, or null when absent. + */ + public function body(): ?array + { + return $this->body; + } + + /** + * Returns the query. + * + * @return array|null The query string parameters, or null when absent. + */ + public function query(): ?array + { + return $this->query; + } + + /** + * Returns the method. + * + * @return Method The HTTP method used by the request. + */ + public function method(): Method + { + return $this->method; + } + + /** + * Returns the headers. + * + * @return Headers The headers carried by the request. + */ + public function headers(): Headers + { + return $this->headers; + } + + /** + * Returns a copy of the Request with the URL replaced. + * + * @param string $url The replacement URL. + * @return Request A new instance with the replaced URL. + */ + public function withUrl(string $url): Request + { + return new Request( + url: $url, + body: $this->body, + query: $this->query, + method: $this->method, + headers: $this->headers + ); + } + + /** + * Returns a copy of the request carrying the given query parameters. + * + * @param array|null $query The query string parameters, or null to clear them. + * @return Request A new instance with the replaced query. + */ + public function withQuery(?array $query): Request + { + return new Request( + url: $this->url, + body: $this->body, + query: $query, + method: $this->method, + headers: $this->headers + ); + } + + /** + * Returns a copy of the Request with the given default headers merged in. + * + * @param Headers $defaults The default headers to merge under existing entries. + * @return Request A new instance carrying the merged headers. + */ + public function withMergedHeaders(Headers $defaults): Request + { + return new Request( + url: $this->url, + body: $this->body, + query: $this->query, + method: $this->method, + headers: $this->headers->mergedWith(other: $defaults) + ); + } +} diff --git a/src/Client/Response.php b/src/Client/Response.php new file mode 100644 index 0000000..2aed34b --- /dev/null +++ b/src/Client/Response.php @@ -0,0 +1,122 @@ +getStatusCode()), + headers: Headers::fromMessage(message: $response) + ); + } + + /** + * Synthesizes a response from a status code and an optional body and headers. + * + * @param Code $code The HTTP status code carried by the synthesized response. + * @param array|null $body The response body as an associative array, or null for an empty body. + * @param Headers|null $headers The response headers, or null for an empty headers instance. + * @return Response A synthesized response without a backing PSR-7 message. + */ + public static function with(Code $code, ?array $body = null, ?Headers $headers = null): Response + { + return new Response( + psr: null, + body: Body::fromArray(data: $body ?? []), + code: $code, + headers: $headers ?? new Headers(entries: []) + ); + } + + /** + * Returns the status code. + * + * @return Code The status code carried by the response. + */ + public function code(): Code + { + return $this->code; + } + + /** + * Returns the body. + * + * @return Body The parsed body of the response. + */ + public function body(): Body + { + return $this->body; + } + + /** + * Returns the headers. + * + * @return Headers The headers carried by the response. + */ + public function headers(): Headers + { + return $this->headers; + } + + /** + * Tells whether the status code denotes an error response. + * + * @return bool True when the code falls in the 4xx or 5xx range, otherwise false. + */ + public function isError(): bool + { + return $this->code->isError(); + } + + /** + * Tells whether the status code denotes a successful response. + * + * @return bool True when the code falls in the 2xx range, otherwise false. + */ + public function isSuccess(): bool + { + return $this->code->isSuccess(); + } + + /** + * Returns the underlying PSR-7 response. + * + * @return ResponseInterface The original PSR-7 response wrapped by this instance. + * @throws SynthesizedResponseHasNoRaw If the response was synthesized via {@see Response::with()} and + * has no backing PSR-7 message. + */ + public function raw(): ResponseInterface + { + if (is_null($this->psr)) { + throw SynthesizedResponseHasNoRaw::create(); + } + + return $this->psr; + } +} diff --git a/src/Client/Transport.php b/src/Client/Transport.php new file mode 100644 index 0000000..e54b599 --- /dev/null +++ b/src/Client/Transport.php @@ -0,0 +1,23 @@ + $responses The pre-built responses served in order on each send. + * @return InMemoryTransport A transport that returns each seeded response in sequence. + */ + public static function with(array $responses): InMemoryTransport + { + return new InMemoryTransport(cursor: new Cursor(), responses: $responses); + } + + public function send(Request $request): Response + { + $index = $this->cursor->advance(); + + if (!isset($this->responses[$index])) { + throw NoMoreResponses::atIndex(index: $index); + } + + return $this->responses[$index]; + } +} diff --git a/src/Client/Transports/NetworkTransport.php b/src/Client/Transports/NetworkTransport.php new file mode 100644 index 0000000..957fb34 --- /dev/null +++ b/src/Client/Transports/NetworkTransport.php @@ -0,0 +1,69 @@ +factory->createRequest($request->method()->value, $request->url()); + $psrRequest = $request->headers()->applyTo(message: $psrRequest); + + $body = $request->body(); + + if (!is_null($body)) { + $encoded = json_encode($body, NetworkTransport::JSON_FLAGS); + $psrRequest = $psrRequest->withBody(body: $this->factory->createStream($encoded)); + } + + try { + $psrResponse = $this->client->sendRequest($psrRequest); + } catch (NetworkExceptionInterface $exception) { + throw HttpNetworkFailed::fromClientException(request: $request, exception: $exception); + } catch (RequestExceptionInterface $exception) { + throw HttpRequestInvalid::fromClientException(request: $request, exception: $exception); + } catch (ClientExceptionInterface $exception) { + throw HttpRequestFailed::fromClientException(request: $request, exception: $exception); + } + + return Response::from(response: $psrResponse); + } +} diff --git a/src/Code.php b/src/Code.php index 424fb82..37e33e6 100644 --- a/src/Code.php +++ b/src/Code.php @@ -90,55 +90,73 @@ enum Code: int case NETWORK_AUTHENTICATION_REQUIRED = 511; /** - * Returns the HTTP status message associated with the enum's code. + * Tells whether the given code falls in the error range (4xx or 5xx). * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages - * @return string The formatted message with the status code and name. + * @param int $code The HTTP status code to check. + * @return bool True when the code falls in the error range, otherwise false. */ - public function message(): string + public static function isErrorCode(int $code): bool { - $subject = match ($this) { - self::OK => $this->name, - self::IM_USED => 'IM Used', - self::IM_A_TEAPOT => "I'm a teapot", - default => mb_convert_case($this->name, MB_CASE_TITLE) - }; + return $code >= Code::BAD_REQUEST->value && $code <= Code::NETWORK_AUTHENTICATION_REQUIRED->value; + } - return str_replace('_', ' ', $subject); + /** + * Tells whether the given code falls in the success range (2xx). + * + * @param int $code The HTTP status code to check. + * @return bool True when the code falls in the success range, otherwise false. + */ + public static function isSuccessCode(int $code): bool + { + return $code >= Code::OK->value && $code <= Code::IM_USED->value; } /** - * Determines if the given code is a valid HTTP status code represented by the enum. + * Tells whether the given code is a valid HTTP status code represented by the enum. * * @param int $code The HTTP status code to check. - * @return bool True if the code exists in the enum, otherwise false. + * @return bool True when the code exists in the enum, otherwise false. */ public static function isValidCode(int $code): bool { - $mapper = fn(Code $code): int => $code->value; + return !is_null(Code::tryFrom($code)); + } - return in_array($code, array_map($mapper, self::cases())); + /** + * Tells whether the status code falls in the 4xx or 5xx range. + * + * @return bool True when the code represents an error response. + */ + public function isError(): bool + { + return Code::isErrorCode(code: $this->value); } /** - * Determines if the given code is in the error range (4xx or 5xx). + * Tells whether the status code falls in the 2xx range. * - * @param int $code The HTTP status code to check. - * @return bool True if the code is in the error range (4xx or 5xx), otherwise false. + * @return bool True when the code represents a successful response. */ - public static function isErrorCode(int $code): bool + public function isSuccess(): bool { - return $code >= self::BAD_REQUEST->value && $code <= self::NETWORK_AUTHENTICATION_REQUIRED->value; + return Code::isSuccessCode(code: $this->value); } /** - * Determines if the given code is in the success range (2xx). + * Returns the HTTP status message associated with the enum's code. * - * @param int $code The HTTP status code to check. - * @return bool True if the code is in the success range (2xx), otherwise false. + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages + * @return string The formatted message with the status code and name. */ - public static function isSuccessCode(int $code): bool + public function message(): string { - return $code >= self::OK->value && $code <= self::IM_USED->value; + $subject = match ($this) { + Code::OK => $this->name, + Code::IM_USED => 'IM Used', + Code::IM_A_TEAPOT => "I'm a teapot", + default => mb_convert_case($this->name, MB_CASE_TITLE) + }; + + return str_replace('_', ' ', $subject); } } diff --git a/src/ContentType.php b/src/ContentType.php index 221ed9f..4a3c137 100644 --- a/src/ContentType.php +++ b/src/ContentType.php @@ -4,43 +4,73 @@ namespace TinyBlocks\Http; -/** - * The Content-Type representation header is used to indicate the original media type - * of the resource (prior to any content encoding applied for sending). - * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type - */ -final readonly class ContentType implements Headers +final readonly class ContentType implements Headerable { private function __construct(private MimeType $mimeType, private ?Charset $charset) { } + /** + * Creates a ContentType for text/html with an optional charset. + * + * @param Charset|null $charset The optional charset folded into the header value. + * @return ContentType A ContentType for text/html. + */ public static function textHtml(?Charset $charset = null): ContentType { return new ContentType(mimeType: MimeType::TEXT_HTML, charset: $charset); } + /** + * Creates a ContentType for text/plain with an optional charset. + * + * @param Charset|null $charset The optional charset folded into the header value. + * @return ContentType A ContentType for text/plain. + */ public static function textPlain(?Charset $charset = null): ContentType { return new ContentType(mimeType: MimeType::TEXT_PLAIN, charset: $charset); } + /** + * Creates a ContentType for application/json with an optional charset. + * + * @param Charset|null $charset The optional charset folded into the header value. + * @return ContentType A ContentType for application/json. + */ public static function applicationJson(?Charset $charset = null): ContentType { return new ContentType(mimeType: MimeType::APPLICATION_JSON, charset: $charset); } + /** + * Creates a ContentType for application/pdf with an optional charset. + * + * @param Charset|null $charset The optional charset folded into the header value. + * @return ContentType A ContentType for application/pdf. + */ public static function applicationPdf(?Charset $charset = null): ContentType { return new ContentType(mimeType: MimeType::APPLICATION_PDF, charset: $charset); } + /** + * Creates a ContentType for application/octet-stream with an optional charset. + * + * @param Charset|null $charset The optional charset folded into the header value. + * @return ContentType A ContentType for application/octet-stream. + */ public static function applicationOctetStream(?Charset $charset = null): ContentType { return new ContentType(mimeType: MimeType::APPLICATION_OCTET_STREAM, charset: $charset); } + /** + * Creates a ContentType for application/x-www-form-urlencoded with an optional charset. + * + * @param Charset|null $charset The optional charset folded into the header value. + * @return ContentType A ContentType for application/x-www-form-urlencoded. + */ public static function applicationFormUrlencoded(?Charset $charset = null): ContentType { return new ContentType(mimeType: MimeType::APPLICATION_FORM_URLENCODED, charset: $charset); @@ -48,10 +78,12 @@ public static function applicationFormUrlencoded(?Charset $charset = null): Cont public function toArray(): array { - $value = $this->charset - ? sprintf('%s; %s', $this->mimeType->value, $this->charset->toString()) - : $this->mimeType->value; + if (is_null($this->charset)) { + return ['Content-Type' => [$this->mimeType->value]]; + } - return ['Content-Type' => [$value]]; + $template = '%s; %s'; + + return ['Content-Type' => [sprintf($template, $this->mimeType->value, $this->charset->toString())]]; } } diff --git a/src/Cookie.php b/src/Cookie.php index b0156a9..6e997ee 100644 --- a/src/Cookie.php +++ b/src/Cookie.php @@ -7,12 +7,14 @@ use DateTimeImmutable; use DateTimeInterface; use DateTimeZone; -use TinyBlocks\Http\Internal\Cookies\CookieName; -use TinyBlocks\Http\Internal\Cookies\CookieValue; -use TinyBlocks\Http\Internal\Exceptions\ConflictingLifetimeAttributes; -use TinyBlocks\Http\Internal\Exceptions\SameSiteNoneRequiresSecure; +use TinyBlocks\Http\Internal\Server\Cookies\CookieName; +use TinyBlocks\Http\Internal\Server\Cookies\CookieValue; +use TinyBlocks\Http\Internal\Server\Exceptions\ConflictingLifetimeAttributes; +use TinyBlocks\Http\Internal\Server\Exceptions\CookieNameIsInvalid; +use TinyBlocks\Http\Internal\Server\Exceptions\CookieValueIsInvalid; +use TinyBlocks\Http\Internal\Server\Exceptions\SameSiteNoneRequiresSecure; -final readonly class Cookie implements Headers +final readonly class Cookie implements Headerable { private const string EXPIRES_FORMAT = 'D, d M Y H:i:s \G\M\T'; @@ -24,44 +26,65 @@ private function __construct( private ?int $maxAge, private bool $secure, private ?DateTimeImmutable $expires, - private ?SameSite $sameSite, private bool $httpOnly, + private ?SameSite $sameSite, private bool $partitioned ) { } + /** + * Creates a Cookie from a name and a value, with no attributes set. + * + * @param string $name The cookie name. + * @param string $value The cookie value. + * @return Cookie A Cookie carrying the given name and value and no other attributes. + * @throws CookieNameIsInvalid If the name is empty or contains forbidden characters. + * @throws CookieValueIsInvalid If the value contains forbidden characters. + */ public static function create(string $name, string $value): Cookie { return new Cookie( - name: CookieName::from($name), + name: CookieName::from(value: $name), path: null, - value: CookieValue::from($value), + value: CookieValue::from(value: $value), domain: null, maxAge: null, secure: false, expires: null, - sameSite: null, httpOnly: false, + sameSite: null, partitioned: false ); } + /** + * Creates a Cookie that instructs the browser to discard an existing cookie with the given name. + * + * @param string $name The cookie name being expired. + * @return Cookie A Cookie with an empty value and Max-Age=0 set. + * @throws CookieNameIsInvalid If the name is empty or contains forbidden characters. + */ public static function expire(string $name): Cookie { return new Cookie( - name: CookieName::from($name), + name: CookieName::from(value: $name), path: null, - value: CookieValue::from(''), + value: CookieValue::from(value: ''), domain: null, maxAge: 0, secure: false, expires: null, - sameSite: null, httpOnly: false, + sameSite: null, partitioned: false ); } + /** + * Returns a copy of the Cookie with the Secure attribute enabled. + * + * @return Cookie A new instance carrying the Secure attribute. + */ public function secure(): Cookie { return new Cookie( @@ -72,12 +95,17 @@ public function secure(): Cookie maxAge: $this->maxAge, secure: true, expires: $this->expires, - sameSite: $this->sameSite, httpOnly: $this->httpOnly, + sameSite: $this->sameSite, partitioned: $this->partitioned ); } + /** + * Returns a copy of the Cookie with the HttpOnly attribute enabled. + * + * @return Cookie A new instance carrying the HttpOnly attribute. + */ public function httpOnly(): Cookie { return new Cookie( @@ -88,12 +116,17 @@ public function httpOnly(): Cookie maxAge: $this->maxAge, secure: $this->secure, expires: $this->expires, - sameSite: $this->sameSite, httpOnly: true, + sameSite: $this->sameSite, partitioned: $this->partitioned ); } + /** + * Returns a copy of the Cookie with the Partitioned attribute enabled. + * + * @return Cookie A new instance carrying the Partitioned attribute. + */ public function partitioned(): Cookie { return new Cookie( @@ -104,12 +137,18 @@ public function partitioned(): Cookie maxAge: $this->maxAge, secure: $this->secure, expires: $this->expires, - sameSite: $this->sameSite, httpOnly: $this->httpOnly, + sameSite: $this->sameSite, partitioned: true ); } + /** + * Returns a copy of the Cookie with the path replaced. + * + * @param string $path The replacement path. + * @return Cookie A new instance carrying the replaced path. + */ public function withPath(string $path): Cookie { return new Cookie( @@ -120,28 +159,41 @@ public function withPath(string $path): Cookie maxAge: $this->maxAge, secure: $this->secure, expires: $this->expires, - sameSite: $this->sameSite, httpOnly: $this->httpOnly, + sameSite: $this->sameSite, partitioned: $this->partitioned ); } + /** + * Returns a copy of the Cookie with the value replaced. + * + * @param string $value The replacement value. + * @return Cookie A new instance carrying the replaced value. + * @throws CookieValueIsInvalid If the value contains forbidden characters. + */ public function withValue(string $value): Cookie { return new Cookie( name: $this->name, path: $this->path, - value: CookieValue::from($value), + value: CookieValue::from(value: $value), domain: $this->domain, maxAge: $this->maxAge, secure: $this->secure, expires: $this->expires, - sameSite: $this->sameSite, httpOnly: $this->httpOnly, + sameSite: $this->sameSite, partitioned: $this->partitioned ); } + /** + * Returns a copy of the Cookie with the domain replaced. + * + * @param string $domain The replacement domain. + * @return Cookie A new instance carrying the replaced domain. + */ public function withDomain(string $domain): Cookie { return new Cookie( @@ -152,12 +204,18 @@ public function withDomain(string $domain): Cookie maxAge: $this->maxAge, secure: $this->secure, expires: $this->expires, - sameSite: $this->sameSite, httpOnly: $this->httpOnly, + sameSite: $this->sameSite, partitioned: $this->partitioned ); } + /** + * Returns a copy of the Cookie with the Max-Age replaced. + * + * @param int $seconds The replacement lifetime in seconds. + * @return Cookie A new instance carrying the replaced Max-Age. + */ public function withMaxAge(int $seconds): Cookie { return new Cookie( @@ -168,12 +226,18 @@ public function withMaxAge(int $seconds): Cookie maxAge: $seconds, secure: $this->secure, expires: $this->expires, - sameSite: $this->sameSite, httpOnly: $this->httpOnly, + sameSite: $this->sameSite, partitioned: $this->partitioned ); } + /** + * Returns a copy of the Cookie with the Expires replaced and normalized to UTC. + * + * @param DateTimeInterface $expires The replacement expiration timestamp; normalized to UTC. + * @return Cookie A new instance carrying the replaced Expires. + */ public function withExpires(DateTimeInterface $expires): Cookie { return new Cookie( @@ -184,12 +248,18 @@ public function withExpires(DateTimeInterface $expires): Cookie maxAge: $this->maxAge, secure: $this->secure, expires: DateTimeImmutable::createFromInterface($expires)->setTimezone(new DateTimeZone('UTC')), - sameSite: $this->sameSite, httpOnly: $this->httpOnly, + sameSite: $this->sameSite, partitioned: $this->partitioned ); } + /** + * Returns a copy of the Cookie with the SameSite attribute replaced. + * + * @param SameSite $sameSite The replacement SameSite attribute. + * @return Cookie A new instance carrying the replaced SameSite attribute. + */ public function withSameSite(SameSite $sameSite): Cookie { return new Cookie( @@ -200,8 +270,8 @@ public function withSameSite(SameSite $sameSite): Cookie maxAge: $this->maxAge, secure: $this->secure, expires: $this->expires, - sameSite: $sameSite, httpOnly: $this->httpOnly, + sameSite: $sameSite, partitioned: $this->partitioned ); } @@ -209,31 +279,36 @@ public function withSameSite(SameSite $sameSite): Cookie public function toArray(): array { $invariantViolation = match (true) { - $this->sameSite === SameSite::NONE && !$this->secure => new SameSiteNoneRequiresSecure(), - !is_null($this->maxAge) && !is_null($this->expires) => new ConflictingLifetimeAttributes(), - default => null, + $this->sameSite === SameSite::NONE && !$this->secure => new SameSiteNoneRequiresSecure(), + !is_null($this->maxAge) && !is_null($this->expires) => new ConflictingLifetimeAttributes(), + default => null }; if (!is_null($invariantViolation)) { throw $invariantViolation; } - $parts = [sprintf('%s=%s', $this->name->toString(), $this->value->toString())]; + $nameValueTemplate = '%s=%s'; + $parts = [sprintf($nameValueTemplate, $this->name->toString(), $this->value->toString())]; if (!is_null($this->maxAge)) { - $parts[] = sprintf('Max-Age=%d', $this->maxAge); + $maxAgeTemplate = 'Max-Age=%d'; + $parts[] = sprintf($maxAgeTemplate, $this->maxAge); } if (!is_null($this->expires)) { - $parts[] = sprintf('Expires=%s', $this->expires->format(self::EXPIRES_FORMAT)); + $expiresTemplate = 'Expires=%s'; + $parts[] = sprintf($expiresTemplate, $this->expires->format(Cookie::EXPIRES_FORMAT)); } if (!is_null($this->path)) { - $parts[] = sprintf('Path=%s', $this->path); + $pathTemplate = 'Path=%s'; + $parts[] = sprintf($pathTemplate, $this->path); } if (!is_null($this->domain)) { - $parts[] = sprintf('Domain=%s', $this->domain); + $domainTemplate = 'Domain=%s'; + $parts[] = sprintf($domainTemplate, $this->domain); } if ($this->secure) { @@ -245,7 +320,8 @@ public function toArray(): array } if (!is_null($this->sameSite)) { - $parts[] = sprintf('SameSite=%s', $this->sameSite->value); + $sameSiteTemplate = 'SameSite=%s'; + $parts[] = sprintf($sameSiteTemplate, $this->sameSite->value); } if ($this->partitioned) { diff --git a/src/Exceptions/HttpConfigurationInvalid.php b/src/Exceptions/HttpConfigurationInvalid.php new file mode 100644 index 0000000..e8fde12 --- /dev/null +++ b/src/Exceptions/HttpConfigurationInvalid.php @@ -0,0 +1,38 @@ +value, $url, $reason), previous: $previous); + } + + /** + * Creates an HttpNetworkFailed from a URL, HTTP method, reason, and optional previous throwable. + * + * @param string $url The URL of the failed request. + * @param Method $method The HTTP method of the failed request. + * @param string $reason The transport-level reason for the failure. + * @param Throwable|null $previous The previous throwable preserved in the exception chain, if any. + * @return HttpNetworkFailed The composed network-failure exception. + */ + public static function from( + string $url, + Method $method, + string $reason, + ?Throwable $previous = null + ): HttpNetworkFailed { + return new HttpNetworkFailed(url: $url, method: $method, reason: $reason, previous: $previous); + } + + /** + * Creates an HttpNetworkFailed from a Request and a PSR-18 network exception. + * + * @param Request $request The outbound request that triggered the failure. + * @param NetworkExceptionInterface $exception The PSR-18 network exception preserved as the previous throwable. + * @return HttpNetworkFailed The composed network-failure exception wrapping the original cause. + */ + public static function fromClientException( + Request $request, + NetworkExceptionInterface $exception + ): HttpNetworkFailed { + return HttpNetworkFailed::from( + url: $request->url(), + method: $request->method(), + reason: $exception->getMessage(), + previous: $exception + ); + } + + public function url(): string + { + return $this->url; + } + + public function method(): Method + { + return $this->method; + } + + public function reason(): string + { + return $this->reason; + } +} diff --git a/src/Exceptions/HttpRequestFailed.php b/src/Exceptions/HttpRequestFailed.php new file mode 100644 index 0000000..dae12ff --- /dev/null +++ b/src/Exceptions/HttpRequestFailed.php @@ -0,0 +1,79 @@ +value, $url, $reason), previous: $previous); + } + + /** + * Creates an HttpRequestFailed from a URL, HTTP method, reason, and optional previous throwable. + * + * @param string $url The URL of the failed request. + * @param Method $method The HTTP method of the failed request. + * @param string $reason The transport-level reason for the failure. + * @param Throwable|null $previous The previous throwable preserved in the exception chain, if any. + * @return HttpRequestFailed The composed request-failure exception. + */ + public static function from( + string $url, + Method $method, + string $reason, + ?Throwable $previous = null + ): HttpRequestFailed { + return new HttpRequestFailed(url: $url, method: $method, reason: $reason, previous: $previous); + } + + /** + * Creates an HttpRequestFailed from a Request and a PSR-18 client exception. + * + * @param Request $request The outbound request that triggered the failure. + * @param ClientExceptionInterface $exception The PSR-18 client exception preserved as the previous throwable. + * @return HttpRequestFailed The composed request-failure exception wrapping the original cause. + */ + public static function fromClientException( + Request $request, + ClientExceptionInterface $exception + ): HttpRequestFailed { + return HttpRequestFailed::from( + url: $request->url(), + method: $request->method(), + reason: $exception->getMessage(), + previous: $exception + ); + } + + public function url(): string + { + return $this->url; + } + + public function method(): Method + { + return $this->method; + } + + public function reason(): string + { + return $this->reason; + } +} diff --git a/src/Exceptions/HttpRequestInvalid.php b/src/Exceptions/HttpRequestInvalid.php new file mode 100644 index 0000000..6f3f5f5 --- /dev/null +++ b/src/Exceptions/HttpRequestInvalid.php @@ -0,0 +1,79 @@ +value, $url, $reason), previous: $previous); + } + + /** + * Creates an HttpRequestInvalid from a URL, HTTP method, reason, and optional previous throwable. + * + * @param string $url The URL of the failed request. + * @param Method $method The HTTP method of the failed request. + * @param string $reason The transport-level reason for the failure. + * @param Throwable|null $previous The previous throwable preserved in the exception chain, if any. + * @return HttpRequestInvalid The composed request-invalid exception. + */ + public static function from( + string $url, + Method $method, + string $reason, + ?Throwable $previous = null + ): HttpRequestInvalid { + return new HttpRequestInvalid(url: $url, method: $method, reason: $reason, previous: $previous); + } + + /** + * Creates an HttpRequestInvalid from a Request and a PSR-18 request exception. + * + * @param Request $request The outbound request that triggered the failure. + * @param RequestExceptionInterface $exception The PSR-18 request exception preserved as the previous throwable. + * @return HttpRequestInvalid The composed request-invalid exception wrapping the original cause. + */ + public static function fromClientException( + Request $request, + RequestExceptionInterface $exception + ): HttpRequestInvalid { + return HttpRequestInvalid::from( + url: $request->url(), + method: $request->method(), + reason: $exception->getMessage(), + previous: $exception + ); + } + + public function url(): string + { + return $this->url; + } + + public function method(): Method + { + return $this->method; + } + + public function reason(): string + { + return $this->reason; + } +} diff --git a/src/Exceptions/MalformedPath.php b/src/Exceptions/MalformedPath.php new file mode 100644 index 0000000..9f6475f --- /dev/null +++ b/src/Exceptions/MalformedPath.php @@ -0,0 +1,43 @@ +url(), previous: $previous); + } + + /** + * Returns the path. + * + * @return string The malformed path that triggered the exception. + */ + public function path(): string + { + return $this->path; + } +} diff --git a/src/Exceptions/NoMoreResponses.php b/src/Exceptions/NoMoreResponses.php new file mode 100644 index 0000000..cc5a0fb --- /dev/null +++ b/src/Exceptions/NoMoreResponses.php @@ -0,0 +1,30 @@ +> An associative array where the key is the header + * name and the value is the header value (or list of values). + */ + public function toArray(): array; +} diff --git a/src/Headers.php b/src/Headers.php index b417a4a..77cba96 100644 --- a/src/Headers.php +++ b/src/Headers.php @@ -4,16 +4,134 @@ namespace TinyBlocks\Http; -/** - * Defines the contract for classes that represent HTTP headers. - */ -interface Headers +use Psr\Http\Message\MessageInterface; + +final readonly class Headers { + private array $entries; + private array $lowerIndex; + + public function __construct(array $entries) + { + $lowerIndex = []; + + foreach ($entries as $name => $value) { + $lowerIndex[strtolower($name)] = $name; + } + + $this->entries = $entries; + $this->lowerIndex = $lowerIndex; + } + + /** + * Creates a Headers from a PSR-7 message, folding multi-value headers with commas. + * + * @param MessageInterface $message The PSR-7 message providing the headers. + * @return Headers A Headers carrying each header from the message, with multi-value entries folded. + */ + public static function fromMessage(MessageInterface $message): Headers + { + $entries = array_map( + static fn(array $values): string => implode(', ', $values), + $message->getHeaders() + ); + + return new Headers(entries: $entries); + } + + /** + * Creates a Headers from a list of Headerable contributors, with the last one winning on collision. + * + * @param Headerable ...$headers The Headerable contributors merged into the result. + * @return Headers A Headers carrying every entry from the supplied contributors. + */ + public static function from(Headerable ...$headers): Headers + { + $entries = []; + + foreach ($headers as $header) { + foreach ($header->toArray() as $name => $value) { + $entries[$name] = is_array($value) ? implode(', ', $value) : $value; + } + } + + return new Headers(entries: $entries); + } + + /** + * Tells whether a header with the given name exists, case-insensitively. + * + * @param string $name The header name to look up. + * @return bool True when a header with that name is present regardless of casing, otherwise false. + */ + public function has(string $name): bool + { + return isset($this->lowerIndex[strtolower($name)]); + } + /** - * Converts the instance to an associative array of HTTP headers. + * Returns the headers as a name to value map. * - * @return array An associative array where the key is the header name - * and the value is the header value. + * @return array The header name to single folded value map. */ - public function toArray(): array; + public function toArray(): array + { + return $this->entries; + } + + /** + * Returns the value associated with the given header name, looking up case-insensitively. + * + * @param string $name The header name to look up. + * @return string|null The folded header value, or null when no entry matches. + */ + public function get(string $name): ?string + { + $key = strtolower($name); + + if (!isset($this->lowerIndex[$key])) { + return null; + } + + return $this->entries[$this->lowerIndex[$key]]; + } + + /** + * Applies every header in this collection to the given PSR-7 message, returning a new instance. + * + * @template T of MessageInterface + * @param T $message The PSR-7 message that receives the headers. + * @return T A new message instance carrying every header. + */ + public function applyTo(MessageInterface $message): MessageInterface + { + $applied = $message; + + foreach ($this->entries as $name => $value) { + $applied = $applied->withHeader($name, $value); + } + + return $applied; + } + + /** + * Returns a copy of these Headers merged with another instance, with existing entries winning on collision. + * + * @param Headers $other The Headers whose entries are merged under the existing ones. + * @return Headers A new instance carrying the union of both sets of headers. + */ + public function mergedWith(Headers $other): Headers + { + $merged = $this->entries; + + foreach ($other->entries as $name => $value) { + if (isset($this->lowerIndex[strtolower($name)])) { + continue; + } + + $merged[$name] = $value; + } + + return new Headers(entries: $merged); + } } diff --git a/src/Http.php b/src/Http.php new file mode 100644 index 0000000..e90408a --- /dev/null +++ b/src/Http.php @@ -0,0 +1,66 @@ +resolver = RequestResolver::withBaseUrl(baseUrl: $baseUrl); + } + + /** + * Returns a fluent builder used to assemble an Http instance. + * + * Both a transport and a base URL must be supplied through the builder + * before calling build(); otherwise HttpConfigurationInvalid is raised. + * + * @return HttpBuilder A new, empty builder. + */ + public static function create(): HttpBuilder + { + return new HttpBuilder(baseUrl: null, transport: null); + } + + /** + * Creates an Http instance directly from a base URL and transport. + * + * Explicit single-call alternative to the fluent builder returned by + * create(). Both arguments are required. + * + * @param string $baseUrl The absolute base URL prepended to every request path. + * @param Transport $transport The transport that delivers resolved requests. + * @return Http A configured Http facade. + */ + public static function with(string $baseUrl, Transport $transport): Http + { + return new Http(baseUrl: $baseUrl, transport: $transport); + } + + /** + * Sends a request through the configured transport and returns the response. + * + * The request is first resolved against the configured base URL and the + * library's JSON defaults. A path that escapes the base URL raises + * MalformedPath before the transport is invoked. Transport-level failures + * surface as HttpException subclasses. + * + * @param Request $request The outbound request to send. + * @return Response The response returned by the transport. + * @throws HttpException When request resolution or the transport fails. + */ + public function send(Request $request): Response + { + return $this->transport->send(request: $this->resolver->resolve(request: $request)); + } +} diff --git a/src/HttpBuilder.php b/src/HttpBuilder.php new file mode 100644 index 0000000..a3ffcca --- /dev/null +++ b/src/HttpBuilder.php @@ -0,0 +1,59 @@ +transport); + } + + /** + * Returns a new builder carrying the given transport. + * + * @param Transport $transport The transport that will deliver resolved requests. + * @return HttpBuilder A new builder instance. + */ + public function withTransport(Transport $transport): HttpBuilder + { + return new HttpBuilder(baseUrl: $this->baseUrl, transport: $transport); + } + + /** + * Assembles the configured Http facade. + * + * Both a base URL and a transport must have been supplied via withBaseUrl() + * and withTransport() before this call. + * + * @return Http A configured Http facade. + * @throws HttpConfigurationInvalid When the base URL or the transport is missing. + */ + public function build(): Http + { + if (is_null($this->transport)) { + throw HttpConfigurationInvalid::missingTransport(); + } + + if (is_null($this->baseUrl)) { + throw HttpConfigurationInvalid::missingBaseUrl(); + } + + return Http::with(baseUrl: $this->baseUrl, transport: $this->transport); + } +} diff --git a/src/Internal/Client/Cursor.php b/src/Internal/Client/Cursor.php new file mode 100644 index 0000000..db3bdf9 --- /dev/null +++ b/src/Internal/Client/Cursor.php @@ -0,0 +1,18 @@ +position; + $this->position++; + + return $current; + } +} diff --git a/src/Internal/Client/Exceptions/PathContainsControlChars.php b/src/Internal/Client/Exceptions/PathContainsControlChars.php new file mode 100644 index 0000000..4f78d07 --- /dev/null +++ b/src/Internal/Client/Exceptions/PathContainsControlChars.php @@ -0,0 +1,24 @@ + 'application/json', + 'Content-Type' => 'application/json' + ]; + + private function __construct(private string $baseUrl) + { + } + + public static function withBaseUrl(string $baseUrl): RequestResolver + { + return new RequestResolver(baseUrl: $baseUrl); + } + + public function resolve(Request $request): Request + { + try { + $url = Url::compose(path: $request->url(), query: $request->query(), baseUrl: $this->baseUrl); + } catch (PathContainsScheme | PathContainsControlChars $exception) { + throw MalformedPath::fromRequest(request: $request, previous: $exception); + } + + return $request + ->withUrl(url: $url) + ->withQuery(query: null) + ->withMergedHeaders(defaults: new Headers(entries: RequestResolver::JSON_DEFAULTS)); + } +} diff --git a/src/Internal/Client/Url.php b/src/Internal/Client/Url.php new file mode 100644 index 0000000..e331598 --- /dev/null +++ b/src/Internal/Client/Url.php @@ -0,0 +1,38 @@ + is invalid. A name must not be empty and must not contain control ', - 'characters, whitespace, or any of the following separators: ( ) < > @ , ; : \\ " / [ ] ? = { }.' - ); - - parent::__construct(sprintf($template, $name)); - } -} diff --git a/src/Internal/Exceptions/CookieValueIsInvalid.php b/src/Internal/Exceptions/CookieValueIsInvalid.php deleted file mode 100644 index 418b71c..0000000 --- a/src/Internal/Exceptions/CookieValueIsInvalid.php +++ /dev/null @@ -1,22 +0,0 @@ - is invalid. A value must not contain control characters, whitespace, ', - 'double quotes, commas, semicolons, or backslashes. Encode the value (e.g., URL-encode or ', - 'Base64) before passing it.' - ); - - parent::__construct(sprintf($template, $value)); - } -} diff --git a/src/Internal/Exceptions/InvalidResource.php b/src/Internal/Exceptions/InvalidResource.php deleted file mode 100644 index 32d8f33..0000000 --- a/src/Internal/Exceptions/InvalidResource.php +++ /dev/null @@ -1,15 +0,0 @@ -getBody(); - $streamFactory = StreamFactory::fromStream(stream: $body); - - if (!$streamFactory->isEmptyContent()) { - return new Body(data: json_decode($streamFactory->content(), true)); - } - - $parsedBody = $request->getParsedBody(); - - if (is_array($parsedBody)) { - return new Body(data: $parsedBody); - } - - return new Body(data: []); - } - - public function get(string $key): Attribute - { - $value = ($this->data[$key] ?? null); - - return Attribute::from(value: $value); - } - - public function toArray(): array - { - return $this->data; - } -} diff --git a/src/Internal/Request/DecodedRequest.php b/src/Internal/Request/DecodedRequest.php deleted file mode 100644 index 24dcc5f..0000000 --- a/src/Internal/Request/DecodedRequest.php +++ /dev/null @@ -1,27 +0,0 @@ -uri; - } - - public function body(): Body - { - return $this->body; - } -} diff --git a/src/Internal/Request/QueryParameters.php b/src/Internal/Request/QueryParameters.php deleted file mode 100644 index 413e17d..0000000 --- a/src/Internal/Request/QueryParameters.php +++ /dev/null @@ -1,31 +0,0 @@ -getQueryParams()); - } - - public function get(string $key): Attribute - { - $value = ($this->data[$key] ?? null); - - return Attribute::from(value: $value); - } - - public function toArray(): array - { - return $this->data; - } -} diff --git a/src/Internal/Request/Uri.php b/src/Internal/Request/Uri.php deleted file mode 100644 index 08a1b1e..0000000 --- a/src/Internal/Request/Uri.php +++ /dev/null @@ -1,123 +0,0 @@ -request->getUri()->__toString(); - } - - /** - * Returns a typed wrapper around the query string parameters. - * - * @return QueryParameters Provides typed access to individual query parameters via get(). - */ - public function queryParameters(): QueryParameters - { - return QueryParameters::from(request: $this->request); - } - - /** - * Returns a new Uri instance configured to read route parameters from the given attribute name. - * - * @param string $name The request attribute name where route params are stored. - * @return Uri A new instance targeting the specified attribute. - */ - public function route(string $name = self::ROUTE): Uri - { - return new Uri( - request: $this->request, - routeAttributeName: $name, - resolver: $this->resolver - ); - } - - /** - * Retrieves a single route parameter by key. - * - * Resolution order: - * 1. Look up the configured attribute name and extract the key from it. - * 2. If not found, scan all known framework attribute keys. - * 3. If still not found, try a direct `getAttribute($key)` on the request. - * 4. Falls back to `Attribute::from(null)` which provides safe defaults. - * - * @param string $key The route parameter name. - * @return Attribute A typed wrapper around the resolved value. - */ - public function get(string $key): Attribute - { - $value = $this->resolveValue(key: $key); - - return Attribute::from(value: $value); - } - - private function resolveValue(string $key): mixed - { - $parameters = $this->resolver->resolve(attributeName: $this->routeAttributeName); - - if (array_key_exists($key, $parameters)) { - return $parameters[$key]; - } - - $attribute = $this->request->getAttribute($this->routeAttributeName); - - if (is_scalar($attribute)) { - return $attribute; - } - - return $this->resolveFromFallbacks(key: $key); - } - - private function resolveFromFallbacks(string $key): mixed - { - if ($this->routeAttributeName === self::ROUTE) { - $allKnown = $this->resolver->resolveFromKnownAttributes(); - - if (array_key_exists($key, $allKnown)) { - return $allKnown[$key]; - } - } - - return $this->resolver->resolveDirectAttribute(key: $key); - } -} diff --git a/src/Internal/CacheControl/CacheControlDirective.php b/src/Internal/Server/CacheControl/CacheControlDirective.php similarity index 55% rename from src/Internal/CacheControl/CacheControlDirective.php rename to src/Internal/Server/CacheControl/CacheControlDirective.php index aa86459..7e73cd8 100644 --- a/src/Internal/CacheControl/CacheControlDirective.php +++ b/src/Internal/Server/CacheControl/CacheControlDirective.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\CacheControl; +namespace TinyBlocks\Http\Internal\Server\CacheControl; trait CacheControlDirective { @@ -12,27 +12,27 @@ private function __construct(private readonly string $value) public static function maxAge(int $maxAgeInWholeSeconds): static { - return new self(value: Directives::MAX_AGE->toHeaderValue(value: $maxAgeInWholeSeconds)); + return new static(value: Directives::MAX_AGE->toHeaderValue(value: $maxAgeInWholeSeconds)); } public static function noCache(): static { - return new self(value: Directives::NO_CACHE->toHeaderValue()); + return new static(value: Directives::NO_CACHE->toHeaderValue()); } public static function noStore(): static { - return new self(value: Directives::NO_STORE->toHeaderValue()); + return new static(value: Directives::NO_STORE->toHeaderValue()); } public static function noTransform(): static { - return new self(value: Directives::NO_TRANSFORM->toHeaderValue()); + return new static(value: Directives::NO_TRANSFORM->toHeaderValue()); } public static function staleIfError(): static { - return new self(value: Directives::STALE_IF_ERROR->toHeaderValue()); + return new static(value: Directives::STALE_IF_ERROR->toHeaderValue()); } public function toString(): string diff --git a/src/Internal/CacheControl/Directives.php b/src/Internal/Server/CacheControl/Directives.php similarity index 62% rename from src/Internal/CacheControl/Directives.php rename to src/Internal/Server/CacheControl/Directives.php index c30eb90..9e1b196 100644 --- a/src/Internal/CacheControl/Directives.php +++ b/src/Internal/Server/CacheControl/Directives.php @@ -2,11 +2,8 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\CacheControl; +namespace TinyBlocks\Http\Internal\Server\CacheControl; -/** - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#cache_directives - */ enum Directives: string { case MAX_AGE = 'max-age'; @@ -19,9 +16,11 @@ enum Directives: string public function toHeaderValue(?int $value = null): string { + $template = '%s=%d'; + return match ($this) { - self::MAX_AGE => sprintf('%s=%d', $this->value, $value), - default => $this->value + Directives::MAX_AGE => sprintf($template, $this->value, $value), + default => $this->value }; } } diff --git a/src/Internal/Cookies/CookieName.php b/src/Internal/Server/Cookies/CookieName.php similarity index 56% rename from src/Internal/Cookies/CookieName.php rename to src/Internal/Server/Cookies/CookieName.php index dd0e375..a2d0169 100644 --- a/src/Internal/Cookies/CookieName.php +++ b/src/Internal/Server/Cookies/CookieName.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Cookies; +namespace TinyBlocks\Http\Internal\Server\Cookies; -use TinyBlocks\Http\Internal\Exceptions\CookieNameIsInvalid; +use TinyBlocks\Http\Internal\Server\Exceptions\CookieNameIsInvalid; final readonly class CookieName { @@ -17,14 +17,14 @@ private function __construct(private string $value) public static function from(string $value): CookieName { if ($value === '' || preg_match('/[\x00-\x1F\x7F]/', $value) === 1) { - throw new CookieNameIsInvalid($value); + throw new CookieNameIsInvalid(name: $value); } - if (strpbrk($value, self::TOKEN_SEPARATORS) !== false) { - throw new CookieNameIsInvalid($value); + if (strpbrk($value, CookieName::TOKEN_SEPARATORS) !== false) { + throw new CookieNameIsInvalid(name: $value); } - return new CookieName($value); + return new CookieName(value: $value); } public function toString(): string diff --git a/src/Internal/Cookies/CookieValue.php b/src/Internal/Server/Cookies/CookieValue.php similarity index 54% rename from src/Internal/Cookies/CookieValue.php rename to src/Internal/Server/Cookies/CookieValue.php index a1dc59d..d75f9a5 100644 --- a/src/Internal/Cookies/CookieValue.php +++ b/src/Internal/Server/Cookies/CookieValue.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Cookies; +namespace TinyBlocks\Http\Internal\Server\Cookies; -use TinyBlocks\Http\Internal\Exceptions\CookieValueIsInvalid; +use TinyBlocks\Http\Internal\Server\Exceptions\CookieValueIsInvalid; final readonly class CookieValue { @@ -17,14 +17,14 @@ private function __construct(private string $value) public static function from(string $value): CookieValue { if (preg_match('/[\x00-\x1F\x7F]/', $value) === 1) { - throw new CookieValueIsInvalid($value); + throw new CookieValueIsInvalid(value: $value); } - if (strpbrk($value, self::FORBIDDEN_CHARACTERS) !== false) { - throw new CookieValueIsInvalid($value); + if (strpbrk($value, CookieValue::FORBIDDEN_CHARACTERS) !== false) { + throw new CookieValueIsInvalid(value: $value); } - return new CookieValue($value); + return new CookieValue(value: $value); } public function toString(): string diff --git a/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php b/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php new file mode 100644 index 0000000..634a93a --- /dev/null +++ b/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php @@ -0,0 +1,18 @@ + is invalid. A name must not be empty and must not contain ' + . 'control characters, whitespace, or any of the following separators: ( ) < > @ , ; : \\ " / [ ] ? = { }.'; + + public function __construct(string $name) + { + $template = CookieNameIsInvalid::REASON_TEMPLATE; + + parent::__construct(message: sprintf($template, $name)); + } +} diff --git a/src/Internal/Server/Exceptions/CookieValueIsInvalid.php b/src/Internal/Server/Exceptions/CookieValueIsInvalid.php new file mode 100644 index 0000000..fe5a65c --- /dev/null +++ b/src/Internal/Server/Exceptions/CookieValueIsInvalid.php @@ -0,0 +1,21 @@ + is invalid. A value must not contain control characters, ' + . 'whitespace, double quotes, commas, semicolons, or backslashes. Encode the value ' + . '(e.g., URL-encode or Base64) before passing it.'; + + public function __construct(string $value) + { + $template = CookieValueIsInvalid::REASON_TEMPLATE; + + parent::__construct(message: sprintf($template, $value)); + } +} diff --git a/src/Internal/Server/Exceptions/MissingResourceStream.php b/src/Internal/Server/Exceptions/MissingResourceStream.php new file mode 100644 index 0000000..3fb9517 --- /dev/null +++ b/src/Internal/Server/Exceptions/MissingResourceStream.php @@ -0,0 +1,17 @@ +resolve(attributeName: $attributeName); + + if (array_key_exists($key, $parameters)) { + return $parameters[$key]; + } + $attribute = $this->request->getAttribute($attributeName); - if (is_array($attribute)) { + if (is_scalar($attribute)) { return $attribute; } - if (is_object($attribute)) { - return $this->extractFromObject(object: $attribute); + return $this->resolveFallback(key: $key, scanKnownAttributes: $scanKnownAttributes); + } + + private function resolveFallback(string $key, bool $scanKnownAttributes): mixed + { + if ($scanKnownAttributes) { + $allKnown = $this->resolveFromKnownAttributes(); + + if (array_key_exists($key, $allKnown)) { + return $allKnown[$key]; + } } - return []; + return $this->request->getAttribute($key); } - public function resolveFromKnownAttributes(): array + private function resolveFromKnownAttributes(): array { - foreach (self::KNOWN_ATTRIBUTE_KEYS as $key) { + foreach (RouteParameterResolver::KNOWN_ATTRIBUTE_KEYS as $key) { $parameters = $this->resolve(attributeName: $key); if (!empty($parameters)) { @@ -77,29 +83,39 @@ public function resolveFromKnownAttributes(): array return []; } - public function resolveDirectAttribute(string $key): mixed + private function resolve(string $attributeName): array { - return $this->request->getAttribute($key); + $attribute = $this->request->getAttribute($attributeName); + + if (is_array($attribute)) { + return $attribute; + } + + if (is_object($attribute)) { + return $this->extractFromObject(object: $attribute); + } + + return []; } private function extractFromObject(object $object): array { - foreach (self::OBJECT_METHODS as $method) { + foreach (RouteParameterResolver::OBJECT_METHODS as $method) { if (method_exists($object, $method)) { - $result = $object->{$method}(); + $parameters = $object->{$method}(); - if (is_array($result)) { - return $result; + if (is_array($parameters)) { + return $parameters; } } } - foreach (self::OBJECT_PROPERTIES as $property) { + foreach (RouteParameterResolver::OBJECT_PROPERTIES as $property) { if (property_exists($object, $property)) { - $value = $object->{$property}; + $parameters = $object->{$property}; - if (is_array($value)) { - return $value; + if (is_array($parameters)) { + return $parameters; } } } diff --git a/src/Internal/Response/InternalResponse.php b/src/Internal/Server/Response/InternalResponse.php similarity index 87% rename from src/Internal/Response/InternalResponse.php rename to src/Internal/Server/Response/InternalResponse.php index 5532442..c61fa8b 100644 --- a/src/Internal/Response/InternalResponse.php +++ b/src/Internal/Server/Response/InternalResponse.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Response; +namespace TinyBlocks\Http\Internal\Server\Response; use Psr\Http\Message\MessageInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; use TinyBlocks\Http\Code; -use TinyBlocks\Http\Headers; -use TinyBlocks\Http\Internal\Stream\StreamFactory; +use TinyBlocks\Http\Headerable; +use TinyBlocks\Http\Internal\Server\Stream\StreamFactory; final readonly class InternalResponse implements ResponseInterface { @@ -21,7 +21,7 @@ private function __construct( ) { } - public static function createWithBody(mixed $body, Code $code, Headers ...$headers): ResponseInterface + public static function createWithBody(mixed $body, Code $code, Headerable ...$headers): ResponseInterface { return new InternalResponse( body: StreamFactory::fromBody(body: $body)->write(), @@ -31,7 +31,7 @@ public static function createWithBody(mixed $body, Code $code, Headers ...$heade ); } - public static function createWithoutBody(Code $code, Headers ...$headers): ResponseInterface + public static function createWithoutBody(Code $code, Headerable ...$headers): ResponseInterface { return new InternalResponse( body: StreamFactory::fromEmptyBody()->write(), @@ -41,6 +41,46 @@ public static function createWithoutBody(Code $code, Headers ...$headers): Respo ); } + public function hasHeader(string $name): bool + { + return $this->headers->hasHeader(name: $name); + } + + public function getBody(): StreamInterface + { + return $this->body; + } + + public function getHeader(string $name): array + { + return $this->headers->getByName(name: $name); + } + + public function getHeaders(): array + { + return $this->headers->toArray(); + } + + public function getStatusCode(): int + { + return $this->code->value; + } + + public function getHeaderLine(string $name): string + { + return implode(', ', $this->getHeader(name: $name)); + } + + public function getReasonPhrase(): string + { + return $this->code->message(); + } + + public function getProtocolVersion(): string + { + return $this->protocolVersion->version; + } + public function withBody(StreamInterface $body): MessageInterface { return new InternalResponse( @@ -61,7 +101,7 @@ public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterf ); } - public function withHeader(string $name, $value): MessageInterface + public function withHeader(string $name, mixed $value): MessageInterface { return new InternalResponse( body: $this->body, @@ -81,7 +121,7 @@ public function withoutHeader(string $name): MessageInterface ); } - public function withAddedHeader(string $name, $value): MessageInterface + public function withAddedHeader(string $name, mixed $value): MessageInterface { return new InternalResponse( body: $this->body, @@ -102,44 +142,4 @@ public function withProtocolVersion(string $version): MessageInterface protocolVersion: $protocolVersion ); } - - public function hasHeader(string $name): bool - { - return $this->headers->hasHeader(name: $name); - } - - public function getBody(): StreamInterface - { - return $this->body; - } - - public function getHeader(string $name): array - { - return $this->headers->getByName(name: $name); - } - - public function getHeaders(): array - { - return $this->headers->toArray(); - } - - public function getStatusCode(): int - { - return $this->code->value; - } - - public function getHeaderLine(string $name): string - { - return implode(', ', $this->headers->getByName(name: $name)); - } - - public function getReasonPhrase(): string - { - return $this->code->message(); - } - - public function getProtocolVersion(): string - { - return $this->protocolVersion->version; - } } diff --git a/src/Internal/Response/ProtocolVersion.php b/src/Internal/Server/Response/ProtocolVersion.php similarity index 59% rename from src/Internal/Response/ProtocolVersion.php rename to src/Internal/Server/Response/ProtocolVersion.php index 2767f9b..5c037be 100644 --- a/src/Internal/Response/ProtocolVersion.php +++ b/src/Internal/Server/Response/ProtocolVersion.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Response; +namespace TinyBlocks\Http\Internal\Server\Response; final readonly class ProtocolVersion { @@ -14,11 +14,11 @@ private function __construct(public string $version) public static function default(): ProtocolVersion { - return new ProtocolVersion(version: self::DEFAULT_PROTOCOL_VERSION); + return new ProtocolVersion(version: ProtocolVersion::DEFAULT_PROTOCOL_VERSION); } public static function from(string $version): ProtocolVersion { - return empty($version) ? self::default() : new ProtocolVersion(version: $version); + return $version === '' ? ProtocolVersion::default() : new ProtocolVersion(version: $version); } } diff --git a/src/Internal/Response/ResponseHeaders.php b/src/Internal/Server/Response/ResponseHeaders.php similarity index 63% rename from src/Internal/Response/ResponseHeaders.php rename to src/Internal/Server/Response/ResponseHeaders.php index 7df7172..632d436 100644 --- a/src/Internal/Response/ResponseHeaders.php +++ b/src/Internal/Server/Response/ResponseHeaders.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Response; +namespace TinyBlocks\Http\Internal\Server\Response; use TinyBlocks\Http\Charset; use TinyBlocks\Http\ContentType; -use TinyBlocks\Http\Headers; +use TinyBlocks\Http\Headerable; -final readonly class ResponseHeaders implements Headers +final readonly class ResponseHeaders { private function __construct(private array $headers) { } - public static function fromOrDefault(Headers ...$headers): ResponseHeaders + public static function fromOrDefault(Headerable ...$headers): ResponseHeaders { if (empty($headers)) { return new ResponseHeaders(headers: ContentType::applicationJson(charset: Charset::UTF_8)->toArray()); @@ -23,7 +23,8 @@ public static function fromOrDefault(Headers ...$headers): ResponseHeaders $merged = []; foreach ($headers as $header) { - foreach ($header->toArray() as $name => $values) { + foreach ($header->toArray() as $name => $value) { + $values = is_array($value) ? $value : [$value]; $merged[$name] = isset($merged[$name]) ? array_merge($merged[$name], $values) : $values; } } @@ -31,78 +32,74 @@ public static function fromOrDefault(Headers ...$headers): ResponseHeaders return new ResponseHeaders(headers: $merged); } + public function hasHeader(string $name): bool + { + return !empty($this->getByName(name: $name)); + } + + public function toArray(): array + { + return $this->headers; + } + public function getByName(string $name): array { $key = $this->findKey(name: $name); - return $key === null ? [] : $this->headers[$key]; + return is_null($key) ? [] : $this->headers[$key]; } - public function hasHeader(string $name): bool + private function findKey(string $name): ?string { - return !empty($this->getByName(name: $name)); + $lowered = strtolower($name); + + return array_find(array_keys($this->headers), static fn(string $key): bool => strtolower($key) === $lowered); } - public function removeByName(string $name): ResponseHeaders + public function withReplaced(string $name, string|array $value): ResponseHeaders { $headers = $this->headers; $existingKey = $this->findKey(name: $name); - - if ($existingKey !== null) { - unset($headers[$existingKey]); - } + $targetKey = $existingKey ?? $name; + $headers[$targetKey] = is_array($value) ? $value : [$value]; return new ResponseHeaders(headers: $headers); } - public function withReplaced(string $name, mixed $value): ResponseHeaders + public function removeByName(string $name): ResponseHeaders { $headers = $this->headers; $existingKey = $this->findKey(name: $name); - $targetKey = $existingKey ?? $name; - $headers[$targetKey] = [$value]; + + if (!is_null($existingKey)) { + unset($headers[$existingKey]); + } return new ResponseHeaders(headers: $headers); } - public function withAdded(string $name, mixed $value): ResponseHeaders + public function withAdded(string $name, string|array $value): ResponseHeaders { $headers = $this->headers; $existingKey = $this->findKey(name: $name); + $appended = is_array($value) ? $value : [$value]; - if ($existingKey === null) { - $headers[$name] = [$value]; + if (is_null($existingKey)) { + $headers[$name] = $appended; return new ResponseHeaders(headers: $headers); } $existingValues = $headers[$existingKey]; - if (in_array($value, $existingValues, strict: true)) { - return new ResponseHeaders(headers: $headers); + foreach ($appended as $next) { + if (!in_array($next, $existingValues, true)) { + $existingValues[] = $next; + } } - $existingValues[] = $value; $headers[$existingKey] = $existingValues; return new ResponseHeaders(headers: $headers); } - - public function toArray(): array - { - return $this->headers; - } - - private function findKey(string $name): ?string - { - $lowered = strtolower($name); - - foreach (array_keys($this->headers) as $key) { - if (strtolower($key) === $lowered) { - return $key; - } - } - - return null; - } } diff --git a/src/Internal/Server/Stream/Stream.php b/src/Internal/Server/Stream/Stream.php new file mode 100644 index 0000000..0d5e0ef --- /dev/null +++ b/src/Internal/Server/Stream/Stream.php @@ -0,0 +1,235 @@ +resource = $resource; + } + + public static function from(mixed $resource): Stream + { + $raw = stream_get_meta_data($resource); + + return new Stream(seekable: $raw['seekable'], resource: $resource); + } + + public function close(): void + { + if (!is_resource($this->resource)) { + return; + } + + $resource = $this->resource; + $this->resource = null; + + fclose($resource); + } + + public function detach(): mixed + { + $resource = $this->resource; + $this->resource = null; + + return $resource; + } + + public function getSize(): ?int + { + if (!is_resource($this->resource)) { + return null; + } + + $size = fstat($this->resource); + + return is_array($size) ? $size['size'] : null; + } + + public function tell(): int + { + if (!is_resource($this->resource)) { + throw new MissingResourceStream(); + } + + return ftell($this->resource); + } + + public function eof(): bool + { + return is_resource($this->resource) && feof($this->resource); + } + + public function seek(int $offset, int $whence = SEEK_SET): void + { + if (!is_resource($this->resource)) { + throw new NonSeekableStream(); + } + + fseek($this->resource, $offset, $whence); + } + + public function rewind(): void + { + $this->seek(Stream::OFFSET_ZERO); + } + + public function read(int $length): string + { + if (!is_resource($this->resource)) { + throw new NonReadableStream(); + } + + if ($length < 1) { + throw new NonReadableStream(); + } + + $chunk = fread($this->resource, $length); + + return $chunk === false ? '' : $chunk; + } + + public function write(string $string): int + { + if (!is_resource($this->resource)) { + throw new NonWritableStream(); + } + + return fwrite($this->resource, $string); + } + + public function isReadable(): bool + { + if (!is_resource($this->resource)) { + return false; + } + + $mode = stream_get_meta_data($this->resource)['mode']; + + return in_array($mode, Stream::READABLE_MODES, true); + } + + public function isWritable(): bool + { + if (!is_resource($this->resource)) { + return false; + } + + $mode = stream_get_meta_data($this->resource)['mode']; + + return in_array($mode, Stream::WRITABLE_MODES, true); + } + + public function isSeekable(): bool + { + return is_resource($this->resource) && $this->seekable; + } + + public function getContents(): string + { + if (!is_resource($this->resource)) { + throw new NonReadableStream(); + } + + $contents = stream_get_contents($this->resource); + + return $contents === false ? '' : $contents; + } + + public function getMetadata(?string $key = null): mixed + { + if (!is_resource($this->resource)) { + return is_null($key) ? [] : null; + } + + $metaData = stream_get_meta_data($this->resource); + + if (is_null($key)) { + return $metaData; + } + + return $metaData[$key] ?? null; + } + + public function __toString(): string + { + if ($this->isSeekable()) { + $this->rewind(); + } + + return $this->getContents(); + } +} diff --git a/src/Internal/Stream/StreamFactory.php b/src/Internal/Server/Stream/StreamFactory.php similarity index 59% rename from src/Internal/Stream/StreamFactory.php rename to src/Internal/Server/Stream/StreamFactory.php index d867f05..0ada976 100644 --- a/src/Internal/Stream/StreamFactory.php +++ b/src/Internal/Server/Stream/StreamFactory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Stream; +namespace TinyBlocks\Http\Internal\Server\Stream; use BackedEnum; use Psr\Http\Message\StreamInterface; @@ -13,29 +13,22 @@ { private Stream $stream; - private function __construct(private mixed $body) + private function __construct(private string $body) { - $this->stream = Stream::from(resource: fopen('php://memory', 'wb+')); + $resource = fopen('php://memory', 'wb+'); + $this->stream = Stream::from(resource: $resource); } - public static function fromBody(mixed $body): StreamFactory + public static function fromEmptyBody(): StreamFactory { - $dataToWrite = match (true) { - is_a($body, Mapper::class) => $body->toJson(), - is_a($body, BackedEnum::class) => self::toJsonFrom(body: $body->value), - is_a($body, UnitEnum::class) => $body->name, - is_object($body) => self::toJsonFrom(body: get_object_vars($body)), - is_string($body) => $body, - is_scalar($body) || is_array($body) => self::toJsonFrom(body: $body), - default => '' - }; - - return new StreamFactory(body: $dataToWrite); + return new StreamFactory(body: ''); } - public static function fromEmptyBody(): StreamFactory + private static function toJsonFrom(mixed $body): string { - return new StreamFactory(body: ''); + $encoded = json_encode($body, JSON_PRESERVE_ZERO_FRACTION); + + return $encoded === false ? '' : $encoded; } public static function fromStream(StreamInterface $stream): StreamFactory @@ -53,26 +46,36 @@ public static function fromStream(StreamInterface $stream): StreamFactory return new StreamFactory(body: $body); } - public function write(): StreamInterface + public static function fromBody(mixed $body): StreamFactory { - $this->stream->write(string: $this->body); - $this->stream->rewind(); + $dataToWrite = match (true) { + $body instanceof Mapper => $body->toJson(), + $body instanceof BackedEnum => StreamFactory::toJsonFrom(body: $body->value), + $body instanceof UnitEnum => $body->name, + is_object($body) => StreamFactory::toJsonFrom(body: get_object_vars($body)), + is_string($body) => $body, + is_scalar($body) || is_array($body) => StreamFactory::toJsonFrom(body: $body), + default => '' + }; - return $this->stream; + return new StreamFactory(body: $dataToWrite); } public function content(): string { - return (string)$this->body; + return $this->body; } public function isEmptyContent(): bool { - return empty($this->body); + return $this->body === ''; } - private static function toJsonFrom(mixed $body): string + public function write(): StreamInterface { - return json_encode($body, JSON_PRESERVE_ZERO_FRACTION); + $this->stream->write($this->body); + $this->stream->rewind(); + + return $this->stream; } } diff --git a/src/Internal/Stream/Stream.php b/src/Internal/Stream/Stream.php deleted file mode 100644 index f46ff17..0000000 --- a/src/Internal/Stream/Stream.php +++ /dev/null @@ -1,172 +0,0 @@ -noResource()) { - return; - } - - /** @var resource $resource */ - $resource = $this->detach(); - - fclose($resource); - } - - public function detach() - { - $resource = $this->resource; - $this->resource = null; - - return $resource; - } - - public function getSize(): ?int - { - if ($this->noResource()) { - return null; - } - - $size = fstat($this->resource); - - return is_array($size) ? $size['size'] : null; - } - - public function tell(): int - { - if ($this->noResource()) { - throw new MissingResourceStream(); - } - - return ftell($this->resource); - } - - public function eof(): bool - { - return $this->resource && feof($this->resource); - } - - public function seek(int $offset, int $whence = SEEK_SET): void - { - if (!$this->isSeekable()) { - throw new NonSeekableStream(); - } - - fseek($this->resource, $offset, $whence); - } - - public function rewind(): void - { - $this->seek(offset: self::OFFSET_ZERO); - } - - public function read(int $length): string - { - if (!$this->isReadable()) { - throw new NonReadableStream(); - } - - return fread($this->resource, $length); - } - - public function write(string $string): int - { - if (!$this->isWritable()) { - throw new NonWritableStream(); - } - - return fwrite($this->resource, $string); - } - - public function isReadable(): bool - { - if ($this->noResource()) { - return false; - } - - $mode = $this->metaData->getMode(); - - return str_contains($mode, 'r') || str_contains($mode, '+'); - } - - public function isWritable(): bool - { - if ($this->noResource()) { - return false; - } - - return strpbrk($this->metaData->getMode(), 'xwca+') !== false; - } - - public function isSeekable(): bool - { - return !$this->noResource() && $this->metaData->isSeekable(); - } - - public function getContents(): string - { - if (!$this->isReadable()) { - throw new NonReadableStream(); - } - - return stream_get_contents($this->resource); - } - - public function getMetadata(?string $key = null): mixed - { - $metaData = $this->metaData->toArray(); - - if (is_null($key)) { - return $metaData; - } - - return $metaData[$key] ?? null; - } - - public function __toString(): string - { - if ($this->isSeekable()) { - $this->rewind(); - } - - return $this->getContents(); - } - - private function noResource(): bool - { - return !is_resource($this->resource); - } -} diff --git a/src/Internal/Stream/StreamMetaData.php b/src/Internal/Stream/StreamMetaData.php deleted file mode 100644 index 7a85248..0000000 --- a/src/Internal/Stream/StreamMetaData.php +++ /dev/null @@ -1,46 +0,0 @@ -mode; - } - - public function isSeekable(): bool - { - return $this->seekable; - } - - public function toArray(): array - { - return [ - 'uri' => $this->uri, - 'mode' => $this->mode, - 'seekable' => $this->seekable, - 'streamType' => $this->streamType - ]; - } -} diff --git a/src/Request.php b/src/Request.php deleted file mode 100644 index 7d65bba..0000000 --- a/src/Request.php +++ /dev/null @@ -1,31 +0,0 @@ -request)->decode(); - } - - public function method(): Method - { - return Method::from(value: $this->request->getMethod()); - } -} diff --git a/src/Response.php b/src/Response.php deleted file mode 100644 index d673034..0000000 --- a/src/Response.php +++ /dev/null @@ -1,71 +0,0 @@ -must-revalidate
    directive. + * + * @return ResponseCacheDirectives A directive that forbids using a stale response. + */ public static function mustRevalidate(): ResponseCacheDirectives { return new ResponseCacheDirectives(value: Directives::MUST_REVALIDATE->toHeaderValue()); } + /** + * Builds a ResponseCacheDirectives with the proxy-revalidate directive. + * + * @return ResponseCacheDirectives A directive that forbids shared caches from using a stale response. + */ public static function proxyRevalidate(): ResponseCacheDirectives { return new ResponseCacheDirectives(value: Directives::PROXY_REVALIDATE->toHeaderValue()); diff --git a/src/Server/Decoded/DecodedRequest.php b/src/Server/Decoded/DecodedRequest.php new file mode 100644 index 0000000..071accf --- /dev/null +++ b/src/Server/Decoded/DecodedRequest.php @@ -0,0 +1,46 @@ +uri; + } + + /** + * Returns the body. + * + * @return Body The decoded request body. + */ + public function body(): Body + { + return $this->body; + } +} diff --git a/src/Server/Decoded/QueryParameters.php b/src/Server/Decoded/QueryParameters.php new file mode 100644 index 0000000..a9bea41 --- /dev/null +++ b/src/Server/Decoded/QueryParameters.php @@ -0,0 +1,49 @@ +getQueryParams()); + } + + /** + * Returns the QueryParameters as an associative array. + * + * @return array The raw query parameters keyed by name. + */ + public function toArray(): array + { + return $this->data; + } + + /** + * Returns the Attribute associated with the given query key. + * + * @param string $key The query parameter name to look up. + * @return Attribute The Attribute wrapping the value, or wrapping null when absent. + */ + public function get(string $key): Attribute + { + $attributeValue = ($this->data[$key] ?? null); + + return Attribute::from(value: $attributeValue); + } +} diff --git a/src/Server/Decoded/Uri.php b/src/Server/Decoded/Uri.php new file mode 100644 index 0000000..82f0b31 --- /dev/null +++ b/src/Server/Decoded/Uri.php @@ -0,0 +1,88 @@ +request->getUri()->__toString(); + } + + /** + * Returns the query parameters carried by the request URI. + * + * @return QueryParameters The QueryParameters value object built from the request. + */ + public function queryParameters(): QueryParameters + { + return QueryParameters::from(request: $this->request); + } + + /** + * Returns the Attribute associated with the given route key. + * + * @param string $key The route attribute key to look up. + * @return Attribute The Attribute wrapping the resolved value, or wrapping null when absent. + */ + public function get(string $key): Attribute + { + $attributeValue = $this->resolver->resolveAttribute( + key: $key, + attributeName: $this->routeAttributeName, + scanKnownAttributes: $this->routeAttributeName === Uri::ROUTE + ); + + return Attribute::from(value: $attributeValue); + } + + /** + * Returns a copy of the Uri scoped to a different route attribute name. + * + * @param string $name The route attribute name to scope the Uri to. + * @return Uri A new Uri scoped to the supplied attribute name. + */ + public function route(string $name = Uri::ROUTE): Uri + { + return new Uri( + request: $this->request, + resolver: $this->resolver, + routeAttributeName: $name + ); + } +} diff --git a/src/Server/Request.php b/src/Server/Request.php new file mode 100644 index 0000000..2ab716f --- /dev/null +++ b/src/Server/Request.php @@ -0,0 +1,48 @@ +request)->decode(); + } + + /** + * Returns the HTTP method as a typed enum. + * + * @return Method The HTTP method of the underlying PSR-7 request. + */ + public function method(): Method + { + return Method::from($this->request->getMethod()); + } +} diff --git a/src/Server/Response.php b/src/Server/Response.php new file mode 100644 index 0000000..c45cb16 --- /dev/null +++ b/src/Server/Response.php @@ -0,0 +1,87 @@ +version)) { + return ['User-Agent' => $this->product]; + } + + $template = '%s/%s'; + + return ['User-Agent' => sprintf($template, $this->product, $this->version)]; + } +} diff --git a/tests/Drivers/Laminas/LaminasTest.php b/tests/Drivers/Laminas/LaminasTest.php index 9bfdd06..e6482d5 100644 --- a/tests/Drivers/Laminas/LaminasTest.php +++ b/tests/Drivers/Laminas/LaminasTest.php @@ -6,17 +6,16 @@ use DateTimeInterface; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; -use PHPUnit\Framework\MockObject\Exception; +use Nyholm\Psr7\ServerRequest; use PHPUnit\Framework\TestCase; -use Psr\Http\Message\ServerRequestInterface; use Test\TinyBlocks\Http\Drivers\Endpoint; use Test\TinyBlocks\Http\Drivers\Middleware; use TinyBlocks\Http\CacheControl; use TinyBlocks\Http\Charset; use TinyBlocks\Http\Code; use TinyBlocks\Http\ContentType; -use TinyBlocks\Http\Response; use TinyBlocks\Http\ResponseCacheDirectives; +use TinyBlocks\Http\Server\Response; final class LaminasTest extends TestCase { @@ -29,66 +28,45 @@ protected function setUp(): void $this->middleware = new Middleware(); } - /** - * @throws Exception - */ - public function testResponseProcessedWithLaminas(): void + public function testProcessWhenLaminasMiddlewareInvokedThenReturnsConfiguredResponse(): void { /** @Given a valid request */ - $request = $this->createStub(ServerRequestInterface::class); + $request = new ServerRequest(method: 'GET', uri: 'https://api.example.com/'); - /** @And the Content-Type for the response is set to application/json with UTF-8 charset */ - $contentType = ContentType::applicationJson(charset: Charset::UTF_8); - - /** @And a Cache-Control header is set with no-cache directive */ - $cacheControl = CacheControl::fromResponseDirectives(noCache: ResponseCacheDirectives::noCache()); - - /** @And an HTTP response is created with a 200 OK status and a body containing the creation timestamp */ - $response = Response::ok(['createdAt' => date(DateTimeInterface::ATOM)], $contentType, $cacheControl); + /** @And an HTTP response is created with a 200 OK status, a JSON body, Content-Type, and Cache-Control */ + $response = Response::ok( + ['createdAt' => date(DateTimeInterface::ATOM)], + ContentType::applicationJson(charset: Charset::UTF_8), + CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()) + ); /** @When the request is processed by the handler */ - $actual = $this->middleware->process(request: $request, handler: new Endpoint(response: $response)); + $actual = $this->middleware->process($request, new Endpoint(response: $response)); - /** @Then the response status should indicate success */ + /** @Then the response is returned through the middleware unchanged */ self::assertSame(Code::OK->value, $actual->getStatusCode()); - - /** @And the response body should match the expected body */ self::assertSame($response->getBody()->__toString(), $actual->getBody()->__toString()); - - /** @And the response headers should match the expected headers */ self::assertSame($response->getHeaders(), $actual->getHeaders()); } - public function testResponseEmissionWithLaminas(): void + public function testEmitWhenLaminasEmitterUsedThenWritesBodyToOutputBuffer(): void { - /** @Given the Content-Type for the response is set to application/json with UTF-8 charset */ - $contentType = ContentType::applicationJson(charset: Charset::UTF_8); - - /** @And a Cache-Control header is set with no-cache directive */ - $cacheControl = CacheControl::fromResponseDirectives(noCache: ResponseCacheDirectives::noCache()); - - /** @And an HTTP response is created with a 200 OK status and a body containing the creation timestamp */ + /** @Given a response with Content-Type, Cache-Control, and a custom header */ $response = Response::ok( ['createdAt' => date(DateTimeInterface::ATOM)], - $contentType, - $cacheControl - )->withHeader(name: 'X-Request-ID', value: '123456'); + ContentType::applicationJson(charset: Charset::UTF_8), + CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()) + )->withHeader('X-Request-ID', '123456'); /** @When the response is emitted */ ob_start(); $this->emitter->emit($response); $actual = ob_get_clean(); - /** @Then the emitted response content should match the response body */ + /** @Then the emitted body matches the response body */ self::assertSame($response->getBody()->__toString(), $actual); - - /** @And the response status code should be 200 */ self::assertSame(200, $response->getStatusCode()); - - /** @And the reason phrase should be 'OK' */ self::assertSame('OK', $response->getReasonPhrase()); - - /** @And the response should contain the X-Request-ID header */ - self::assertSame('123456', $response->getHeaderLine(name: 'X-Request-ID')); + self::assertSame('123456', $response->getHeaderLine('X-Request-ID')); } } diff --git a/tests/Drivers/Middleware.php b/tests/Drivers/Middleware.php index 93ff112..942f84c 100644 --- a/tests/Drivers/Middleware.php +++ b/tests/Drivers/Middleware.php @@ -13,6 +13,6 @@ { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - return $handler->handle(request: $request); + return $handler->handle($request); } } diff --git a/tests/Drivers/Slim/SlimTest.php b/tests/Drivers/Slim/SlimTest.php index f41aa45..3d8c1db 100644 --- a/tests/Drivers/Slim/SlimTest.php +++ b/tests/Drivers/Slim/SlimTest.php @@ -5,9 +5,8 @@ namespace Test\TinyBlocks\Http\Drivers\Slim; use DateTimeInterface; -use PHPUnit\Framework\MockObject\Exception; +use Nyholm\Psr7\ServerRequest; use PHPUnit\Framework\TestCase; -use Psr\Http\Message\ServerRequestInterface; use Slim\ResponseEmitter; use Test\TinyBlocks\Http\Drivers\Endpoint; use Test\TinyBlocks\Http\Drivers\Middleware; @@ -15,8 +14,8 @@ use TinyBlocks\Http\Charset; use TinyBlocks\Http\Code; use TinyBlocks\Http\ContentType; -use TinyBlocks\Http\Response; use TinyBlocks\Http\ResponseCacheDirectives; +use TinyBlocks\Http\Server\Response; final class SlimTest extends TestCase { @@ -29,66 +28,45 @@ protected function setUp(): void $this->middleware = new Middleware(); } - /** - * @throws Exception - */ - public function testResponseProcessedWithSlim(): void + public function testProcessWhenSlimMiddlewareInvokedThenReturnsConfiguredResponse(): void { /** @Given a valid request */ - $request = $this->createStub(ServerRequestInterface::class); + $request = new ServerRequest(method: 'GET', uri: 'https://api.example.com/'); - /** @And the Content-Type for the response is set to application/json with UTF-8 charset */ - $contentType = ContentType::applicationJson(charset: Charset::UTF_8); - - /** @And a Cache-Control header is set with no-cache directive */ - $cacheControl = CacheControl::fromResponseDirectives(noCache: ResponseCacheDirectives::noCache()); - - /** @And an HTTP response is created with a 200 OK status and a body containing the creation timestamp */ - $response = Response::ok(['createdAt' => date(DateTimeInterface::ATOM)], $contentType, $cacheControl); + /** @And an HTTP response is created with a 200 OK status, a JSON body, Content-Type, and Cache-Control */ + $response = Response::ok( + ['createdAt' => date(DateTimeInterface::ATOM)], + ContentType::applicationJson(charset: Charset::UTF_8), + CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()) + ); /** @When the request is processed by the handler */ - $actual = $this->middleware->process(request: $request, handler: new Endpoint(response: $response)); + $actual = $this->middleware->process($request, new Endpoint(response: $response)); - /** @Then the response status should indicate success */ + /** @Then the response is returned through the middleware unchanged */ self::assertSame(Code::OK->value, $actual->getStatusCode()); - - /** @And the response body should match the expected body */ self::assertSame($response->getBody()->__toString(), $actual->getBody()->__toString()); - - /** @And the response headers should match the expected headers */ self::assertSame($response->getHeaders(), $actual->getHeaders()); } - public function testResponseEmissionWithSlim(): void + public function testEmitWhenSlimEmitterUsedThenWritesBodyToOutputBuffer(): void { - /** @Given the Content-Type for the response is set to application/json with UTF-8 charset */ - $contentType = ContentType::applicationJson(charset: Charset::UTF_8); - - /** @And a Cache-Control header is set with no-cache directive */ - $cacheControl = CacheControl::fromResponseDirectives(noCache: ResponseCacheDirectives::noCache()); - - /** @And an HTTP response is created with a 200 OK status and a body containing the creation timestamp */ + /** @Given a response with Content-Type, Cache-Control, and a custom header */ $response = Response::ok( ['createdAt' => date(DateTimeInterface::ATOM)], - $contentType, - $cacheControl - )->withHeader(name: 'X-Request-ID', value: '123456'); + ContentType::applicationJson(charset: Charset::UTF_8), + CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()) + )->withHeader('X-Request-ID', '123456'); /** @When the response is emitted */ ob_start(); $this->emitter->emit($response); $actual = ob_get_clean(); - /** @Then the emitted response content should match the response body */ + /** @Then the emitted body matches the response body */ self::assertSame($response->getBody()->__toString(), $actual); - - /** @And the response status code should be 200 */ self::assertSame(200, $response->getStatusCode()); - - /** @And the reason phrase should be 'OK' */ self::assertSame('OK', $response->getReasonPhrase()); - - /** @And the response should contain the X-Request-ID header */ - self::assertSame('123456', $response->getHeaderLine(name: 'X-Request-ID')); + self::assertSame('123456', $response->getHeaderLine('X-Request-ID')); } } diff --git a/tests/HeadersTest.php b/tests/HeadersTest.php deleted file mode 100644 index 8757325..0000000 --- a/tests/HeadersTest.php +++ /dev/null @@ -1,335 +0,0 @@ - ['application/json; charset=utf-8']], $response->getHeaders()); - - /** @When we add custom headers to the response */ - $actual = $response - ->withHeader(name: 'X-ID', value: 100) - ->withHeader(name: 'X-NAME', value: 'Xpto'); - - /** @Then the response should contain the correct headers */ - self::assertSame( - ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => [100], 'X-NAME' => ['Xpto']], - $actual->getHeaders() - ); - - /** @And when we update the 'X-ID' header with a new value */ - $actual = $actual->withHeader(name: 'X-ID', value: 200); - - /** @Then the response should contain the updated 'X-ID' header value */ - self::assertSame('200', $actual->withAddedHeader(name: 'X-ID', value: 200)->getHeaderLine(name: 'X-ID')); - self::assertSame( - ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => [200], 'X-NAME' => ['Xpto']], - $actual->getHeaders() - ); - - /** @And when we remove the 'X-NAME' header */ - $actual = $actual->withoutHeader(name: 'X-NAME'); - - /** @Then the response should contain only the 'X-ID' header and the default 'Content-Type' header */ - self::assertSame( - ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => [200]], - $actual->getHeaders() - ); - } - - public function testResponseWithDuplicatedHeader(): void - { - /** @Given an HTTP response with a 'Content-Type' header set to 'application/json; charset=utf-8' */ - $response = Response::noContent(); - - /** @When we add the 'Content-Type' header twice with different values */ - $actual = $response - ->withHeader(name: 'Content-Type', value: 'application/json; charset=utf-8') - ->withHeader(name: 'Content-Type', value: 'application/json; charset=ISO-8859-1'); - - /** @Then the response should contain the latest 'Content-Type' value */ - self::assertSame('application/json; charset=ISO-8859-1', $actual->getHeaderLine(name: 'Content-Type')); - - /** @And the headers should only contain the last 'Content-Type' value */ - self::assertSame(['Content-Type' => ['application/json; charset=ISO-8859-1']], $actual->getHeaders()); - } - - public function testResponseHeadersWithNoCustomHeader(): void - { - /** @Given an HTTP response with no custom headers */ - $response = Response::noContent(); - - /** @When we retrieve the header that doesn't exist */ - $actual = $response->getHeader(name: 'Non-Existent-Header'); - - /** @Then the header should return an empty array */ - self::assertSame([], $actual); - } - - public function testAddHeaderAppendsDistinctValuesToExistingHeader(): void - { - /** @Given an HTTP response with a custom header */ - $response = Response::noContent()->withHeader(name: 'X-Trace', value: 'first'); - - /** @When a distinct value is added to the same header */ - $actual = $response->withAddedHeader(name: 'X-Trace', value: 'second'); - - /** @Then both values should be preserved in the original order */ - self::assertSame('first, second', $actual->getHeaderLine(name: 'X-Trace')); - self::assertSame(['first', 'second'], $actual->getHeader(name: 'X-Trace')); - } - - public function testAddHeaderCreatesHeaderWhenAbsent(): void - { - /** @Given an HTTP response without a custom header */ - $response = Response::noContent(); - - /** @When a value is added for the absent header */ - $actual = $response->withAddedHeader(name: 'X-Trace', value: 'only-value'); - - /** @Then the header should be created carrying the given value */ - self::assertSame(['only-value'], $actual->getHeader(name: 'X-Trace')); - self::assertSame( - ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['only-value']], - $actual->getHeaders() - ); - } - - public function testAddHeaderIsCaseInsensitiveWhenMatchingExistingHeader(): void - { - /** @Given an HTTP response with a custom header */ - $response = Response::noContent()->withHeader(name: 'X-Trace', value: 'first'); - - /** @When a value is added using a differently cased header name */ - $actual = $response->withAddedHeader(name: 'x-trace', value: 'second'); - - /** @Then the value should be appended preserving the original case of the header name */ - self::assertSame(['first', 'second'], $actual->getHeader(name: 'X-Trace')); - self::assertSame( - ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['first', 'second']], - $actual->getHeaders() - ); - } - - public function testWithoutHeaderIsCaseInsensitive(): void - { - /** @Given an HTTP response with a custom header */ - $response = Response::noContent()->withHeader(name: 'X-Trace', value: 'value'); - - /** @When the header is removed using a differently cased name */ - $actual = $response->withoutHeader(name: 'x-trace'); - - /** @Then the header should no longer be present */ - self::assertFalse($actual->hasHeader(name: 'X-Trace')); - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); - } - - public function testWithoutHeaderIsNoOpWhenHeaderIsAbsent(): void - { - /** @Given an HTTP response without the target header */ - $response = Response::noContent(); - - /** @When the missing header is requested to be removed */ - $actual = $response->withoutHeader(name: 'X-Trace'); - - /** @Then the headers should remain unchanged */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); - } - - public function testReplaceHeaderCreatesHeaderWhenAbsent(): void - { - /** @Given an HTTP response without the target header */ - $response = Response::noContent(); - - /** @When the header is replaced (i.e., set) */ - $actual = $response->withHeader(name: 'X-Trace', value: 'value'); - - /** @Then the header should be created with the given value */ - self::assertSame(['value'], $actual->getHeader(name: 'X-Trace')); - } - - public function testReplaceHeaderIsCaseInsensitiveOnExistingHeader(): void - { - /** @Given an HTTP response with a custom header */ - $response = Response::noContent()->withHeader(name: 'X-Trace', value: 'first'); - - /** @When the header is replaced using a differently cased name */ - $actual = $response->withHeader(name: 'x-trace', value: 'second'); - - /** @Then the original casing of the header name should be preserved and the value replaced */ - self::assertSame(['second'], $actual->getHeader(name: 'X-Trace')); - self::assertSame( - ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['second']], - $actual->getHeaders() - ); - } - - public function testMergingMultipleHeadersCombinesEntries(): void - { - /** @Given a Cache-Control and a Content-Type header */ - $cacheControl = CacheControl::fromResponseDirectives(noStore: ResponseCacheDirectives::noStore()); - $contentType = ContentType::textPlain(); - - /** @When a response is created with both */ - $actual = Response::noContent($cacheControl, $contentType); - - /** @Then both headers should be present */ - self::assertSame(['no-store'], $actual->getHeader(name: 'Cache-Control')); - self::assertSame(['text/plain'], $actual->getHeader(name: 'Content-Type')); - } - - public function testResponseWithCacheControl(): void - { - /** @Given a Cache-Control header with multiple directives */ - $cacheControl = CacheControl::fromResponseDirectives( - maxAge: ResponseCacheDirectives::maxAge(maxAgeInWholeSeconds: 10000), - noCache: ResponseCacheDirectives::noCache(), - noStore: ResponseCacheDirectives::noStore(), - noTransform: ResponseCacheDirectives::noTransform(), - staleIfError: ResponseCacheDirectives::staleIfError(), - mustRevalidate: ResponseCacheDirectives::mustRevalidate(), - proxyRevalidate: ResponseCacheDirectives::proxyRevalidate() - ); - - /** @When we create an HTTP response with no content, using the provided Cache-Control header */ - $actual = Response::noContent($cacheControl); - - /** @And the response should include a Cache-Control header */ - self::assertTrue($actual->hasHeader(name: 'Cache-Control')); - - /** @And the Cache-Control header should match the provided directives */ - $expected = 'max-age=10000, no-cache, no-store, no-transform, stale-if-error, must-revalidate, proxy-revalidate'; - - self::assertSame($expected, $actual->getHeaderLine(name: 'Cache-Control')); - self::assertSame([$expected], $actual->getHeader(name: 'Cache-Control')); - self::assertSame($cacheControl->toArray(), $actual->getHeaders()); - } - - public function testResponseWithContentTypePDF(): void - { - /** @Given the Content-Type header is set to application/pdf */ - $contentType = ContentType::applicationPdf(); - - /** @When we create an HTTP response with no content, using the provided Content-Type */ - $actual = Response::noContent($contentType); - - /** @Then the response should include a Content-Type header */ - self::assertTrue($actual->hasHeader(name: 'Content-Type')); - - /** @And the Content-Type header should be set to application/pdf */ - $expected = 'application/pdf'; - - self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); - self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); - self::assertSame(['Content-Type' => [$expected]], $actual->getHeaders()); - } - - public function testResponseWithContentTypeHTML(): void - { - /** @Given the Content-Type header is set to text/html */ - $contentType = ContentType::textHtml(); - - /** @When we create an HTTP response with no content, using the provided Content-Type */ - $actual = Response::noContent($contentType); - - /** @Then the response should include a Content-Type header */ - self::assertTrue($actual->hasHeader(name: 'Content-Type')); - - /** @And the Content-Type header should be set to text/html */ - $expected = 'text/html'; - - self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); - self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); - self::assertSame(['Content-Type' => [$expected]], $actual->getHeaders()); - } - - public function testResponseWithContentTypeJSON(): void - { - /** @Given the Content-Type header is set to application/json */ - $contentType = ContentType::applicationJson(); - - /** @When we create an HTTP response with no content, using the provided Content-Type */ - $actual = Response::noContent($contentType); - - /** @Then the response should include a Content-Type header */ - self::assertTrue($actual->hasHeader(name: 'Content-Type')); - - /** @And the Content-Type header should be set to application/json */ - $expected = 'application/json'; - - self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); - self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); - self::assertSame(['Content-Type' => [$expected]], $actual->getHeaders()); - } - - public function testResponseWithContentTypePlainText(): void - { - /** @Given the Content-Type header is set to text/plain */ - $contentType = ContentType::textPlain(); - - /** @When we create an HTTP response with no content, using the provided Content-Type */ - $actual = Response::noContent($contentType); - - /** @Then the response should include a Content-Type header */ - self::assertTrue($actual->hasHeader(name: 'Content-Type')); - - /** @And the Content-Type header should be set to text/plain */ - $expected = 'text/plain'; - - self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); - self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); - self::assertSame(['Content-Type' => [$expected]], $actual->getHeaders()); - } - - public function testResponseWithContentTypeOctetStream(): void - { - /** @Given the Content-Type header is set to application/octet-stream */ - $contentType = ContentType::applicationOctetStream(); - - /** @When we create an HTTP response with no content, using the provided Content-Type */ - $actual = Response::noContent($contentType); - - /** @Then the response should include a Content-Type header */ - self::assertTrue($actual->hasHeader(name: 'Content-Type')); - - /** @And the Content-Type header should be set to application/octet-stream */ - $expected = 'application/octet-stream'; - - self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); - self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); - self::assertSame(['Content-Type' => [$expected]], $actual->getHeaders()); - } - - public function testResponseWithContentTypeFormUrlencoded(): void - { - /** @Given the Content-Type header is set to application/x-www-form-urlencoded */ - $contentType = ContentType::applicationFormUrlencoded(); - - /** @When we create an HTTP response with no content, using the provided Content-Type */ - $actual = Response::noContent($contentType); - - /** @Then the response should include a Content-Type header */ - self::assertTrue($actual->hasHeader(name: 'Content-Type')); - - /** @And the Content-Type header should be set to application/x-www-form-urlencoded */ - $expected = 'application/x-www-form-urlencoded'; - - self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); - self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); - self::assertSame(['Content-Type' => [$expected]], $actual->getHeaders()); - } -} diff --git a/tests/Internal/Request/RouteParameterResolverTest.php b/tests/Internal/Request/RouteParameterResolverTest.php deleted file mode 100644 index 64577bf..0000000 --- a/tests/Internal/Request/RouteParameterResolverTest.php +++ /dev/null @@ -1,214 +0,0 @@ -createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => ['id' => '42', 'slug' => 'test'], - default => null - }); - - /** @When resolving parameters */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - $params = $resolver->resolve(attributeName: '__route__'); - - /** @Then the array should be returned directly */ - self::assertSame(['id' => '42', 'slug' => 'test'], $params); - } - - public function testResolveWithObjectUsingGetArguments(): void - { - /** @Given a Slim-style route object */ - $routeObject = new class { - public function getArguments(): array - { - return ['id' => '1', 'name' => 'dragon']; - } - }; - - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => $routeObject, - default => null - }); - - /** @When resolving parameters */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - $params = $resolver->resolve(attributeName: '__route__'); - - /** @Then getArguments() result should be returned */ - self::assertSame(['id' => '1', 'name' => 'dragon'], $params); - } - - public function testResolveWithObjectUsingGetMatchedParams(): void - { - /** @Given a Mezzio-style route result object */ - $routeResult = new class { - public function getMatchedParams(): array - { - return ['id' => '99', 'action' => 'view']; - } - }; - - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - 'routeResult' => $routeResult, - default => null - }); - - /** @When resolving parameters */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - $params = $resolver->resolve(attributeName: 'routeResult'); - - /** @Then getMatchedParams() result should be returned */ - self::assertSame(['id' => '99', 'action' => 'view'], $params); - } - - public function testResolveWithObjectUsingPublicProperty(): void - { - /** @Given a route object with a public arguments property */ - $routeObject = new class { - public array $arguments = ['key' => 'value']; - }; - - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => $routeObject, - default => null - }); - - /** @When resolving parameters */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - $params = $resolver->resolve(attributeName: '__route__'); - - /** @Then the public property value should be returned */ - self::assertSame(['key' => 'value'], $params); - } - - public function testResolveReturnsEmptyArrayWhenAttributeIsNull(): void - { - /** @Given a request with no matching attribute */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturn(null); - - /** @When resolving parameters */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - $params = $resolver->resolve(attributeName: '__route__'); - - /** @Then an empty array should be returned */ - self::assertSame([], $params); - } - - public function testResolveReturnsEmptyArrayForUnextractableObject(): void - { - /** @Given a route object without known methods or properties */ - $routeObject = new class { - public function unknownMethod(): string - { - return 'not useful'; - } - }; - - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => $routeObject, - default => null - }); - - /** @When resolving parameters */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - $params = $resolver->resolve(attributeName: '__route__'); - - /** @Then an empty array should be returned */ - self::assertSame([], $params); - } - - public function testResolveFromKnownAttributesScansMultipleKeys(): void - { - /** @Given params stored under _route_params (Symfony-style) */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '_route_params' => ['controller' => 'DragonController', 'id' => '5'], - default => null - }); - - /** @When scanning known attributes */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - $params = $resolver->resolveFromKnownAttributes(); - - /** @Then the Symfony-style params should be found */ - self::assertSame(['controller' => 'DragonController', 'id' => '5'], $params); - } - - public function testResolveDirectAttribute(): void - { - /** @Given a request with direct attributes (Laravel-style) */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - 'id' => '123', - default => null - }); - - /** @When resolving a direct attribute */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - - /** @Then the direct value should be returned */ - self::assertSame('123', $resolver->resolveDirectAttribute(key: 'id')); - self::assertNull($resolver->resolveDirectAttribute(key: 'nonexistent')); - } - - public function testResolveWithObjectMethodPriorityOverProperty(): void - { - /** @Given an object that has both a method and a property */ - $routeObject = new class { - public array $arguments = ['source' => 'property']; - - public function getArguments(): array - { - return ['source' => 'method']; - } - }; - - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => $routeObject, - default => null - }); - - /** @When resolving parameters */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - $params = $resolver->resolve(attributeName: '__route__'); - - /** @Then the method result should take priority */ - self::assertSame(['source' => 'method'], $params); - } -} diff --git a/tests/Internal/Stream/StreamFactoryTest.php b/tests/Internal/Stream/StreamFactoryTest.php deleted file mode 100644 index e90f0f9..0000000 --- a/tests/Internal/Stream/StreamFactoryTest.php +++ /dev/null @@ -1,89 +0,0 @@ -write(); - - /** @Then the stream should contain the written content */ - self::assertSame($body, $stream->getContents()); - } - - public function testFromStreamShouldRewindBeforeAndAfterReadingWhenSeekable(): void - { - /** @Given a seekable stream */ - $stream = $this->createStub(StreamInterface::class); - $stream->method('isSeekable')->willReturn(true); - - /** @And rewind call counter */ - $rewindCalls = 0; - - /** @And rewind increments the counter */ - $stream->method('rewind')->willReturnCallback( - static function () use (&$rewindCalls): void { - $rewindCalls++; - } - ); - - /** @And getContents must be called after the first rewind */ - $stream->method('getContents')->willReturnCallback( - static function () use (&$rewindCalls): string { - self::assertSame(1, $rewindCalls); - return 'body'; - } - ); - - /** @When a StreamFactory is created from the stream */ - StreamFactory::fromStream(stream: $stream); - - /** @Then it must rewind twice (before and after reading) */ - self::assertSame(2, $rewindCalls); - } - - public function testFromStreamShouldNotRewindWhenNotSeekable(): void - { - /** @Given a non-seekable stream */ - $stream = $this->createStub(StreamInterface::class); - $stream->method('isSeekable')->willReturn(false); - - /** @And rewind call counter */ - $rewindCalls = 0; - - /** @And rewind increments the counter */ - $stream->method('rewind')->willReturnCallback( - static function () use (&$rewindCalls): void { - $rewindCalls++; - } - ); - - /** @And getContents must be called without any rewind */ - $stream->method('getContents')->willReturnCallback( - static function () use (&$rewindCalls): string { - self::assertSame(0, $rewindCalls); - return 'body'; - } - ); - - /** @When a StreamFactory is created from the stream */ - StreamFactory::fromStream(stream: $stream); - - /** @Then it must not rewind */ - self::assertSame(0, $rewindCalls); - } -} diff --git a/tests/Internal/Stream/StreamTest.php b/tests/Internal/Stream/StreamTest.php deleted file mode 100644 index a831e44..0000000 --- a/tests/Internal/Stream/StreamTest.php +++ /dev/null @@ -1,299 +0,0 @@ -temporary = tempnam(sys_get_temp_dir(), 'test'); - $this->resource = fopen($this->temporary, 'wb+'); - } - - protected function tearDown(): void - { - if (!empty($this->temporary) && file_exists($this->temporary)) { - unlink($this->temporary); - } - } - - public function testGetMetadata(): void - { - /** @Given a stream */ - $stream = Stream::from(resource: $this->resource); - - /** @When retrieving metadata */ - $actual = $stream->getMetadata(); - - /** @Then the metadata should match the expected values */ - $expected = StreamMetaData::from(data: stream_get_meta_data($this->resource))->toArray(); - - self::assertSame($expected['uri'], $actual['uri']); - self::assertSame($expected['mode'], $actual['mode']); - self::assertSame($expected['seekable'], $actual['seekable']); - self::assertSame($expected['streamType'], $actual['streamType']); - } - - public function testCloseWithoutResource(): void - { - /** @Given a stream that has already been closed */ - $stream = Stream::from(resource: $this->resource); - $stream->close(); - - /** @When closing the stream again */ - $stream->close(); - - /** @Then the stream should remain closed and detached */ - self::assertFalse($stream->isReadable()); - self::assertFalse($stream->isWritable()); - self::assertFalse($stream->isSeekable()); - self::assertFalse(is_resource($this->resource)); - } - - public function testCloseDetachesResource(): void - { - /** @Given a stream resource */ - $stream = Stream::from(resource: $this->resource); - - /** @When the stream is closed */ - $stream->close(); - - /** @Then the stream should be detached and no longer readable, writable, or seekable */ - self::assertFalse($stream->isReadable()); - self::assertFalse($stream->isWritable()); - self::assertFalse($stream->isSeekable()); - self::assertFalse(is_resource($this->resource)); - } - - public function testSeekMovesCursorPosition(): void - { - /** @Given a stream with data */ - $stream = Stream::from(resource: $this->resource); - $stream->write(string: 'Hello, world!'); - - /** @When seeking to a specific position */ - $stream->seek(offset: 7); - $tellAfterFirstSeek = $stream->tell(); - $stream->seek(offset: 0, whence: SEEK_END); - - /** @Then the cursor position should be updated correctly */ - self::assertTrue($stream->isWritable()); - self::assertTrue($stream->isSeekable()); - self::assertSame(7, $tellAfterFirstSeek); - self::assertSame(13, $stream->tell()); - } - - public function testGetSizeReturnsCorrectSize(): void - { - /** @Given a stream */ - $stream = Stream::from(resource: $this->resource); - - /** @When writing to the stream */ - $sizeBeforeWrite = $stream->getSize(); - $stream->write(string: 'Hello, world!'); - - /** @Then the size should be updated correctly */ - self::assertSame(0, $sizeBeforeWrite); - self::assertSame(13, $stream->getSize()); - } - - public function testIsWritableForCreateMode(): void - { - /** @Given a file that does not exist */ - unlink($this->temporary); - - /** @When opening the stream in create mode ('x') */ - $stream = Stream::from(resource: fopen($this->temporary, 'x')); - - /** @Then the stream should be writable */ - self::assertTrue($stream->isWritable()); - } - - #[DataProvider('modesDataProvider')] - public function testIsWritableForVariousModes(string $mode, bool $expected): void - { - /** @Given a stream opened in a specific mode */ - $stream = Stream::from(resource: fopen('php://memory', $mode)); - - /** @Then check if the stream is writable based on the mode */ - self::assertSame($expected, $stream->isWritable()); - } - - public function testRewindResetsCursorPosition(): void - { - /** @Given a stream with data */ - $stream = Stream::from(resource: $this->resource); - $stream->write(string: 'Hello, world!'); - - /** @When rewinding the stream */ - $stream->seek(offset: 7); - $stream->rewind(); - - /** @Then the cursor position should be reset to the beginning */ - self::assertSame(0, $stream->tell()); - } - - public function testEofReturnsTrueAtEndOfStream(): void - { - /** @Given a stream with data */ - $stream = Stream::from(resource: $this->resource); - $stream->write(string: 'Hello'); - - /** @When reaching the end of the stream */ - $eofBeforeRead = $stream->eof(); - $stream->read(length: 5); - - /** @Then EOF should return true */ - self::assertTrue($stream->eof()); - self::assertTrue($stream->isReadable()); - self::assertFalse($eofBeforeRead); - } - - public function testGetMetadataWhenKeyIsUnknown(): void - { - /** @Given a stream */ - $stream = Stream::from(resource: $this->resource); - - /** @When retrieving metadata for an unknown key */ - $actual = $stream->getMetadata(key: 'UNKNOWN'); - - /** @Then the result should be null */ - self::assertNull($actual); - } - - public function testToStringRewindsStreamIfNotSeekable(): void - { - /** @Given a stream */ - $stream = Stream::from(resource: $this->resource); - - /** @When writing and converting the stream to string */ - $stream->write(string: 'Hello, world!'); - - /** @Then the content should match the written data */ - self::assertSame('Hello, world!', (string)$stream); - } - - public function testGetSizeReturnsNullWhenWithoutResource(): void - { - /** @Given a stream that has been closed */ - $stream = Stream::from(resource: $this->resource); - $stream->close(); - - /** @Then getSize should return null */ - self::assertNull($stream->getSize()); - } - - public function testIsSeekableReturnsFalseWhenUnderlyingResourceIsClosedExternally(): void - { - /** @Given a stream whose underlying resource was closed outside the stream API */ - $resource = fopen('php://memory', 'w+'); - $stream = Stream::from(resource: $resource); - fclose($resource); - - /** @When checking if the stream is seekable */ - $actual = $stream->isSeekable(); - - /** @Then it should return false because the resource is no longer valid */ - self::assertFalse($actual); - } - - public function testExceptionWhenNonSeekableStream(): void - { - /** @Given a stream */ - $stream = Stream::from(resource: $this->resource); - - /** @When attempting to seek on a closed stream */ - self::expectException(NonSeekableStream::class); - self::expectExceptionMessage('Stream is not seekable.'); - - $stream->close(); - $stream->seek(offset: 1); - } - - public function testExceptionWhenNonWritableStream(): void - { - /** @Given a read-only stream */ - $stream = Stream::from(resource: fopen($this->temporary, 'r')); - - /** @When attempting to write to the stream */ - self::expectException(NonWritableStream::class); - self::expectExceptionMessage('Stream is not writable.'); - - $stream->write(string: 'Hello, world!'); - } - - public function testExceptionWhenNonReadableStreamOnRead(): void - { - /** @Given a write-only stream */ - $stream = Stream::from(resource: fopen($this->temporary, 'w')); - - /** @When attempting to read from the stream */ - self::expectException(NonReadableStream::class); - self::expectExceptionMessage('Stream is not readable.'); - - $stream->read(length: 13); - } - - public function testExceptionWhenInvalidResourceProvided(): void - { - /** @Given an invalid resource (e.g., a string) */ - $resource = 'not_a_resource'; - - /** @Then an InvalidResource exception should be thrown */ - $this->expectException(InvalidResource::class); - $this->expectExceptionMessage('The provided value is not a valid resource.'); - - /** @When calling from method with an invalid resource */ - Stream::from(resource: $resource); - } - - public function testExceptionWhenMissingResourceStreamOnTell(): void - { - /** @Given a stream */ - $stream = Stream::from(resource: $this->resource); - - /** @When attempting to call tell on a closed stream */ - self::expectException(MissingResourceStream::class); - self::expectExceptionMessage('No resource available.'); - - $stream->close(); - $stream->tell(); - } - - public function testExceptionWhenNonReadableStreamOnGetContents(): void - { - /** @Given a write-only stream */ - $stream = Stream::from(resource: fopen($this->temporary, 'w')); - - /** @When attempting to get contents of the stream */ - self::expectException(NonReadableStream::class); - self::expectExceptionMessage('Stream is not readable.'); - - $stream->getContents(); - } - - public static function modesDataProvider(): array - { - return [ - 'Read mode (r)' => ['mode' => 'r', 'expected' => false], - 'Write mode (w)' => ['mode' => 'w', 'expected' => true], - 'Append mode (a)' => ['mode' => 'a', 'expected' => true], - 'Mixed read/write mode (r+)' => ['mode' => 'r+', 'expected' => true] - ]; - } -} diff --git a/tests/Models/Products.php b/tests/Models/Products.php index ac1cab6..23bd3b7 100644 --- a/tests/Models/Products.php +++ b/tests/Models/Products.php @@ -18,7 +18,7 @@ final class Products implements IterableMapper, IteratorAggregate public function __construct(iterable $elements = []) { - $this->elements = is_array($elements) ? $elements : iterator_to_array($elements); + $this->elements = is_array($elements) ? array_values($elements) : iterator_to_array($elements, false); } public function getIterator(): Traversable diff --git a/tests/RequestTest.php b/tests/RequestTest.php deleted file mode 100644 index 2f6d767..0000000 --- a/tests/RequestTest.php +++ /dev/null @@ -1,616 +0,0 @@ - PHP_INT_MAX, - 'name' => 'Drakengard Firestorm', - 'type' => 'Dragon', - 'weight' => 6000.00, - 'skills' => ['Fire Breath', 'Flight', 'Regeneration'], - 'is_legendary' => true - ]; - - /** @And this payload is used to create a ServerRequestInterface */ - $stream = $this->createStub(StreamInterface::class); - $stream - ->method('getContents') - ->willReturn(json_encode($payload, JSON_PRESERVE_ZERO_FRACTION)); - - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('POST'); - $serverRequest - ->method('getBody') - ->willReturn($stream); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we decode the body of the HTTP Request */ - $actual = $request->decode()->body(); - - /** @Then the decoded body should match the original payload */ - self::assertSame($payload, $actual->toArray()); - self::assertSame($payload['id'], $actual->get(key: 'id')->toInteger()); - self::assertSame($payload['name'], $actual->get(key: 'name')->toString()); - self::assertSame($payload['type'], $actual->get(key: 'type')->toString()); - self::assertSame($payload['weight'], $actual->get(key: 'weight')->toFloat()); - self::assertSame($payload['skills'], $actual->get(key: 'skills')->toArray()); - self::assertSame($payload['is_legendary'], $actual->get(key: 'is_legendary')->toBoolean()); - } - - public function testRequestDecodingWithRouteWithSingleAttribute(): void - { - /** @Given a route name to be retrieved */ - $routeName = '/v1/dragons/{id}'; - - /** @And an id to be retrieved from the route attribute */ - $attribute = 'dragon-id'; - - /** @And a ServerRequestInterface with this route attribute */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => ['name' => $routeName, 'id' => $attribute], - default => null - }); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we decode the route attribute of the HTTP Request */ - $actual = $request->decode()->uri()->route()->get(key: 'id'); - - self::assertSame($attribute, $actual->toString()); - } - - public function testRequestDecodingWithRouteWithMultipleAttributes(): void - { - /** @Given a route name to be retrieved */ - $routeName = '/v1/dragons/{id}/skills/{skill}'; - - /** @And an id and skill to be retrieved from the route attribute */ - $attributes = [ - 'id' => 'dragon-id', - 'skill' => 'dragon-skill', - 'weight' => 6000.00 - ]; - - /** @And a ServerRequestInterface with this route attribute */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => ['name' => $routeName, ...$attributes], - default => null - }); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we decode the route attribute of the HTTP Request */ - $route = $request->decode()->uri()->route(); - - self::assertSame($attributes['id'], $route->get(key: 'id')->toString()); - self::assertSame($attributes['skill'], $route->get(key: 'skill')->toString()); - self::assertSame($attributes['weight'], $route->get(key: 'weight')->toFloat()); - } - - #[DataProvider('attributeConversionsProvider')] - public function testRequestWhenAttributeConversions( - string $key, - mixed $value, - string $method, - mixed $expected - ): void { - /** @Given a ServerRequestInterface with a route attribute */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => ['name' => '/v1/dragons/{id}', $key => $value], - default => null - }); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we decode the route attribute of the HTTP Request and convert it to the expected type */ - $actual = $request->decode()->uri()->route()->get(key: $key)->$method(); - - /** @Then the converted value should match the expected value */ - self::assertSame($expected, $actual); - } - - public function testRequestDecodingWithRouteAttributeAsScalar(): void - { - /** @Given a scalar route attribute value */ - $attribute = 'dragon-id'; - - /** @And a ServerRequestInterface with this route attribute as scalar */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => $attribute, - default => null - }); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we decode the route attribute of the HTTP Request */ - $actual = $request->decode()->uri()->route()->get(key: 'id'); - - /** @Then the decoded attribute should match the original scalar value */ - self::assertSame($attribute, $actual->toString()); - } - - public function testRequestDecodingWithSlimStyleRouteObject(): void - { - /** @Given a Slim-style route object that stores params in getArguments() */ - $routeObject = new class { - public function getArguments(): array - { - return ['id' => '42', 'email' => 'dragon@fire.com']; - } - }; - - /** @And a ServerRequestInterface with this route object under __route__ */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => $routeObject, - default => null - }); - - /** @When we create the HTTP Request and decode route params */ - $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - - /** @Then the params should be correctly resolved from the object */ - self::assertSame('42', $route->get(key: 'id')->toString()); - self::assertSame(42, $route->get(key: 'id')->toInteger()); - self::assertSame('dragon@fire.com', $route->get(key: 'email')->toString()); - } - - public function testRequestDecodingWithMezzioStyleRouteResult(): void - { - /** @Given a Mezzio-style route result object that uses getMatchedParams() */ - $routeResult = new class { - /** @noinspection PhpUnused */ - public function getMatchedParams(): array - { - return ['id' => '99', 'slug' => 'fire-dragon']; - } - }; - - /** @And a ServerRequestInterface with this route result under routeResult */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - 'routeResult' => $routeResult, - default => null - }); - - /** @When we create the HTTP Request and decode using known attribute scan */ - $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - - /** @Then the params should be correctly resolved from the Mezzio object */ - self::assertSame('99', $route->get(key: 'id')->toString()); - self::assertSame('fire-dragon', $route->get(key: 'slug')->toString()); - } - - public function testRequestDecodingWithSymfonyStyleRouteParams(): void - { - /** @Given Symfony stores route params as an array under _route_params */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '_route_params' => ['id' => '7', 'category' => 'legendary'], - default => null - }); - - /** @When we use the custom route attribute name */ - $route = Request::from(request: $serverRequest) - ->decode() - ->uri() - ->route(name: '_route_params'); - - /** @Then the params should be correctly resolved */ - self::assertSame('7', $route->get(key: 'id')->toString()); - self::assertSame('legendary', $route->get(key: 'category')->toString()); - } - - public function testRequestDecodingWithSymfonyStyleFallbackScan(): void - { - /** @Given Symfony stores route params under _route_params and default __route__ is null */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '_route_params' => ['id' => '55'], - default => null - }); - - /** @When we use the default route() without specifying a name */ - $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - - /** @Then the fallback scan should find params under _route_params */ - self::assertSame('55', $route->get(key: 'id')->toString()); - } - - public function testRequestDecodingWithDirectAttributes(): void - { - /** @Given a framework like Laravel stores route params as direct request attributes */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - 'id' => '123', - 'email' => 'user@example.com', - default => null - }); - - /** @When we decode route params using the default route */ - $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - - /** @Then direct attributes should be resolved as fallback */ - self::assertSame('123', $route->get(key: 'id')->toString()); - self::assertSame('user@example.com', $route->get(key: 'email')->toString()); - } - - public function testRequestDecodingWithManualWithAttribute(): void - { - /** @Given a user manually injects route params via withAttribute() */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('POST'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => ['id' => 'manually-injected', 'status' => 'active'], - default => null - }); - - /** @When we decode route params */ - $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - - /** @Then the manually injected values should be returned */ - self::assertSame('manually-injected', $route->get(key: 'id')->toString()); - self::assertSame('active', $route->get(key: 'status')->toString()); - } - - public function testRequestDecodingWithObjectHavingPublicProperty(): void - { - /** @Given an object that exposes route params via a public property */ - $routeObject = new class { - public array $arguments = ['id' => '10', 'name' => 'Hydra']; - }; - - /** @And a ServerRequestInterface with this object under __route__ */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => $routeObject, - default => null - }); - - /** @When we decode route params */ - $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - - /** @Then public property values should be resolved */ - self::assertSame('10', $route->get(key: 'id')->toString()); - self::assertSame('Hydra', $route->get(key: 'name')->toString()); - } - - public function testRequestDecodingReturnsDefaultsWhenNoRouteParams(): void - { - /** @Given a ServerRequestInterface with no route attributes at all */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturn(null); - - /** @When we try to decode route params */ - $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - - /** @Then safe defaults should be returned */ - self::assertSame(0, $route->get(key: 'id')->toInteger()); - self::assertSame('', $route->get(key: 'name')->toString()); - self::assertSame(0.00, $route->get(key: 'weight')->toFloat()); - self::assertFalse($route->get(key: 'active')->toBoolean()); - self::assertSame([], $route->get(key: 'tags')->toArray()); - } - - public function testRequestDecodingWithParsedBody(): void - { - /** @Given a payload already parsed by the framework */ - $payload = [ - 'id' => PHP_INT_MAX, - 'name' => 'Drakengard Firestorm', - 'type' => 'Dragon', - 'weight' => 6000.00, - 'skills' => ['Fire Breath', 'Flight', 'Regeneration'], - 'is_legendary' => true - ]; - - /** @And a ServerRequestInterface with an empty stream but a parsed body */ - $stream = $this->createStub(StreamInterface::class); - $stream - ->method('getContents') - ->willReturn(''); - - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('POST'); - $serverRequest - ->method('getBody') - ->willReturn($stream); - $serverRequest - ->method('getParsedBody') - ->willReturn($payload); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we decode the body of the HTTP Request */ - $actual = $request->decode()->body(); - - /** @Then the decoded body should match the parsed payload */ - self::assertSame($payload, $actual->toArray()); - self::assertSame($payload['id'], $actual->get(key: 'id')->toInteger()); - self::assertSame($payload['name'], $actual->get(key: 'name')->toString()); - self::assertSame($payload['type'], $actual->get(key: 'type')->toString()); - self::assertSame($payload['weight'], $actual->get(key: 'weight')->toFloat()); - self::assertSame($payload['skills'], $actual->get(key: 'skills')->toArray()); - self::assertSame($payload['is_legendary'], $actual->get(key: 'is_legendary')->toBoolean()); - } - - public function testRequestDecodingWithFullUri(): void - { - /** @Given a full URI string */ - $expectedUri = 'https://api.example.com/v1/dragons?sort=name&order=asc'; - - /** @And a PSR-7 UriInterface mock that returns this URI */ - $uri = $this->createStub(UriInterface::class); - $uri - ->method('__toString') - ->willReturn($expectedUri); - - /** @And a ServerRequestInterface that returns this UriInterface */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getUri') - ->willReturn($uri); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we retrieve the full URI string from the decoded request */ - $actual = $request->decode()->uri()->toString(); - - /** @Then the URI string should match the expected full URI */ - self::assertSame($expectedUri, $actual); - } - - public function testRequestDecodingWithQueryParameters(): void - { - /** @Given query parameters present in the request URI */ - $queryParams = [ - 'sort' => 'name', - 'order' => 'asc', - 'limit' => '50', - 'active' => 'true' - ]; - - /** @And a ServerRequestInterface that returns these query parameters */ - $stream = $this->createStub(StreamInterface::class); - $stream - ->method('getContents') - ->willReturn(''); - - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getQueryParams') - ->willReturn($queryParams); - $serverRequest - ->method('getBody') - ->willReturn($stream); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we retrieve the query parameters from the decoded request */ - $actual = $request->decode()->uri()->queryParameters(); - - /** @Then the query parameters should match the original values */ - self::assertSame($queryParams, $actual->toArray()); - self::assertSame($queryParams['sort'], $actual->get(key: 'sort')->toString()); - self::assertSame($queryParams['order'], $actual->get(key: 'order')->toString()); - self::assertSame(50, $actual->get(key: 'limit')->toInteger()); - self::assertTrue($actual->get(key: 'active')->toBoolean()); - } - - public function testRequestDecodingWithQueryParametersReturnsDefaultsWhenEmpty(): void - { - /** @Given a ServerRequestInterface with no query parameters */ - $stream = $this->createStub(StreamInterface::class); - $stream - ->method('getContents') - ->willReturn(''); - - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getQueryParams') - ->willReturn([]); - $serverRequest - ->method('getBody') - ->willReturn($stream); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we try to access query parameters that do not exist */ - $actual = $request->decode()->uri()->queryParameters(); - - /** @Then safe defaults should be returned */ - self::assertSame([], $actual->toArray()); - self::assertSame('', $actual->get(key: 'sort')->toString()); - self::assertSame(0, $actual->get(key: 'page')->toInteger()); - self::assertSame(0.00, $actual->get(key: 'price')->toFloat()); - self::assertFalse($actual->get(key: 'active')->toBoolean()); - } - - public function testRequestWithMethod(): void - { - /** @Given a ServerRequestInterface with POST method */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('POST'); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we retrieve the HTTP method */ - $actual = $request->method(); - - /** @Then the method should match the expected enum value */ - self::assertSame(Method::POST, $actual); - self::assertSame('POST', $actual->value); - } - - #[DataProvider('httpMethodsProvider')] - public function testRequestWithDifferentHttpMethods(string $methodString, Method $expectedMethod): void - { - /** @Given a ServerRequestInterface with the specified HTTP method */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn($methodString); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we retrieve the HTTP method */ - $actual = $request->method(); - - /** @Then the method should match the expected enum value */ - self::assertSame($expectedMethod, $actual); - self::assertSame($methodString, $actual->value); - } - - public static function httpMethodsProvider(): array - { - return [ - 'GET method' => ['GET', Method::GET], - 'PUT method' => ['PUT', Method::PUT], - 'POST method' => ['POST', Method::POST], - 'HEAD method' => ['HEAD', Method::HEAD], - 'PATCH method' => ['PATCH', Method::PATCH], - 'TRACE method' => ['TRACE', Method::TRACE], - 'DELETE method' => ['DELETE', Method::DELETE], - 'OPTIONS method' => ['OPTIONS', Method::OPTIONS], - 'CONNECT method' => ['CONNECT', Method::CONNECT] - ]; - } - - public static function attributeConversionsProvider(): array - { - return [ - 'Float attribute conversion toString' => ['weight', 6000.00, 'toString', '6000'], - 'Float attribute conversion toInteger' => ['weight', 6000.00, 'toInteger', 6000], - 'Float attribute conversion toBoolean' => ['weight', 6000.00, 'toBoolean', true], - 'String attribute conversion toArray' => [ - 'skills', - '["Fire Breath", "Flight", "Regeneration"]', - 'toArray', - [] - ], - 'String attribute conversion toFloat' => ['weight', '6000.00', 'toFloat', 6000.00], - 'String attribute conversion toInteger' => ['id', '123', 'toInteger', 123], - 'String attribute conversion toBoolean' => [ - 'is_legendary', - 'true', - 'toBoolean', - true - ], - 'Integer attribute conversion toString' => ['id', 123, 'toString', '123'], - 'Integer attribute conversion toFloat' => ['id', 123, 'toFloat', 123.0], - 'Integer attribute conversion toBoolean' => ['id', 123, 'toBoolean', true], - 'Boolean attribute conversion toString' => ['is_legendary', true, 'toString', '1'], - 'Boolean attribute conversion toInteger' => ['is_legendary', true, 'toInteger', 1], - 'Boolean attribute conversion toFloat' => ['is_legendary', true, 'toFloat', 1.0], - 'Non-scalar attribute conversion toFloat defaults to 0.00' => ['meta', ['x' => 1], 'toFloat', 0.00], - 'Non-scalar attribute conversion toInteger defaults to 0' => ['meta', ['x' => 1], 'toInteger', 0], - 'Non-scalar attribute conversion toString defaults to empty' => ['meta', ['x' => 1], 'toString', ''], - 'Non-scalar attribute conversion toBoolean defaults to false' => ['meta', ['x' => 1], 'toBoolean', false] - ]; - } -} diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php deleted file mode 100644 index db0dc39..0000000 --- a/tests/ResponseTest.php +++ /dev/null @@ -1,509 +0,0 @@ -getProtocolVersion()); - - /** @And the body of the response should match the expected output */ - self::assertSame($expectedBody, $actual->getBody()->__toString()); - - /** @And the status code should match the provided code */ - self::assertSame($code->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should match the provided code message */ - self::assertSame($code->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); - } - - public function testResponseOk(): void - { - /** @Given a body with data */ - $body = [ - 'id' => PHP_INT_MAX, - 'name' => 'Drakengard Firestorm', - 'type' => 'Dragon', - 'weight' => 6000.00 - ]; - - /** @When we create the HTTP response with this body */ - $actual = Response::ok(body: $body); - - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 200 */ - self::assertSame(Code::OK->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); - self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "OK" */ - self::assertSame(Code::OK->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); - } - - public function testResponseCreated(): void - { - /** @Given a body with data */ - $body = [ - 'id' => 1, - 'name' => 'New Resource', - 'type' => 'Item', - 'weight' => 100.00 - ]; - - /** @When we create the HTTP response with this body */ - $actual = Response::created(body: $body); - - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 201 */ - self::assertSame(Code::CREATED->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); - self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Created" */ - self::assertSame(Code::CREATED->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); - } - - public function testResponseAccepted(): void - { - /** @Given a body with data */ - $body = [ - 'id' => 1, - 'status' => 'Processing' - ]; - - /** @When we create the HTTP response with this body */ - $actual = Response::accepted(body: $body); - - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 202 */ - self::assertSame(Code::ACCEPTED->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); - self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Accepted" */ - self::assertSame(Code::ACCEPTED->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); - } - - public function testResponseNoContent(): void - { - /** @Given I have no data for the body */ - /** @When we create the HTTP response without body */ - $actual = Response::noContent(); - - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should be empty */ - self::assertEmpty($actual->getBody()->__toString()); - - /** @And the status code should be 204 */ - self::assertSame(Code::NO_CONTENT->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); - self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "No Content" */ - self::assertSame(Code::NO_CONTENT->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); - } - - public function testResponseBadRequest(): void - { - /** @Given a body with error details */ - $body = [ - 'error' => 'Invalid request', - 'message' => 'The request body is malformed.' - ]; - - /** @When we create the HTTP response with this body */ - $actual = Response::badRequest(body: $body); - - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 400 */ - self::assertSame(Code::BAD_REQUEST->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); - self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Bad Request" */ - self::assertSame(Code::BAD_REQUEST->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); - } - - public function testResponseUnauthorized(): void - { - /** @Given a body with error details */ - $body = [ - 'error' => 'Unauthorized', - 'message' => 'Authentication is required to access this resource.' - ]; - - /** @When we create the HTTP response with this body */ - $actual = Response::unauthorized(body: $body); - - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 401 */ - self::assertSame(Code::UNAUTHORIZED->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); - self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Unauthorized" */ - self::assertSame(Code::UNAUTHORIZED->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); - } - - public function testResponseForbidden(): void - { - /** @Given a body with error details */ - $body = [ - 'error' => 'Forbidden', - 'message' => 'You do not have permission to access this resource.' - ]; - - /** @When we create the HTTP response with this body */ - $actual = Response::forbidden(body: $body); - - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 403 */ - self::assertSame(Code::FORBIDDEN->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); - self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Forbidden" */ - self::assertSame(Code::FORBIDDEN->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); - } - - public function testResponseNotFound(): void - { - /** @Given a body with error details */ - $body = [ - 'error' => 'Not found', - 'message' => 'The requested resource could not be found.' - ]; - - /** @When we create the HTTP response with this body */ - $actual = Response::notFound(body: $body); - - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 404 */ - self::assertSame(Code::NOT_FOUND->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); - self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Not Found" */ - self::assertSame(Code::NOT_FOUND->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); - } - - public function testResponseConflict(): void - { - /** @Given a body with conflict details */ - $body = [ - 'error' => 'Conflict', - 'message' => 'There is a conflict with the current state of the resource.' - ]; - - /** @When we create the HTTP response with this body */ - $actual = Response::conflict(body: $body); - - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 409 */ - self::assertSame(Code::CONFLICT->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); - self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Conflict" */ - self::assertSame(Code::CONFLICT->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); - } - - public function testResponseUnprocessableEntity(): void - { - /** @Given a body with validation errors */ - $body = [ - 'error' => 'Validation Failed', - 'message' => 'The input data did not pass validation.' - ]; - - /** @When we create the HTTP response with this body */ - $actual = Response::unprocessableEntity(body: $body); - - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 422 */ - self::assertSame(Code::UNPROCESSABLE_ENTITY->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); - self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Unprocessable Entity" */ - self::assertSame(Code::UNPROCESSABLE_ENTITY->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); - } - - public function testResponseInternalServerError(): void - { - /** @Given a body with error details */ - $body = [ - 'code' => 10000, - 'message' => 'An unexpected error occurred on the server.' - ]; - - /** @When we create the HTTP response with this body */ - $actual = Response::internalServerError(body: $body); - - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 500 */ - self::assertSame(Code::INTERNAL_SERVER_ERROR->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); - self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Internal Server Error" */ - self::assertSame(Code::INTERNAL_SERVER_ERROR->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); - } - - public static function responseFromProvider(): array - { - return [ - 'I am a teapot' => [ - 'code' => Code::IM_A_TEAPOT, - 'body' => 'Short and stout', - 'expectedBody' => 'Short and stout' - ], - 'OK with array body' => [ - 'code' => Code::OK, - 'body' => ['status' => 'success'], - 'expectedBody' => '{"status":"success"}' - ], - 'Accepted with null body' => [ - 'code' => Code::ACCEPTED, - 'body' => null, - 'expectedBody' => '' - ], - 'Not Found with string body' => [ - 'code' => Code::NOT_FOUND, - 'body' => 'Resource not found', - 'expectedBody' => 'Resource not found' - ], - 'Internal Server Error with complex body' => [ - 'code' => Code::INTERNAL_SERVER_ERROR, - 'body' => ['error' => ['code' => 500, 'message' => 'Crash']], - 'expectedBody' => '{"error":{"code":500,"message":"Crash"}}' - ] - ]; - } - - #[DataProvider('bodyProviderData')] - public function testResponseBodySerialization(mixed $body, string $expected): void - { - /** @Given the body contains the provided data */ - /** @When we create an HTTP response with the given body */ - $actual = Response::ok(body: $body); - - /** @Then the body of the response should match the expected output */ - self::assertSame($expected, $actual->getBody()->__toString()); - } - - public function testResponseWithBody(): void - { - /** @Given an HTTP response with without body */ - $response = Response::ok(body: null); - - /** @When the body of the response is initially empty */ - self::assertEmpty($response->getBody()->__toString()); - - /** @And a new body is set for the response */ - $body = 'This is a new body'; - $actual = $response->withBody(body: StreamFactory::fromBody(body: $body)->write()); - - /** @Then the response body should be updated to match the new content */ - self::assertSame($body, $actual->getBody()->__toString()); - } - - public function testWithStatusReturnsResponseWithUpdatedCode(): void - { - /** @Given an HTTP response */ - $response = Response::noContent(); - - /** @When calling withStatus with a new code */ - $updated = $response->withStatus(Code::OK->value); - - /** @Then the returned response reflects the new status code */ - self::assertSame(Code::OK->value, $updated->getStatusCode()); - } - - public static function bodyProviderData(): array - { - return [ - 'UnitEnum' => [ - 'body' => Color::RED, - 'expected' => 'RED' - ], - 'BackedEnum' => [ - 'body' => Status::PAID, - 'expected' => '1' - ], - 'Null value' => [ - 'body' => null, - 'expected' => '' - ], - 'Empty string' => [ - 'body' => '', - 'expected' => '' - ], - 'Simple object' => [ - 'body' => new Dragon(name: 'Drakengard Firestorm', weight: 6000.0), - 'expected' => '{"name":"Drakengard Firestorm","weight":6000.0}' - ], - 'Non-empty string' => [ - 'body' => 'Hello, World!', - 'expected' => 'Hello, World!' - ], - 'Serializer object' => [ - 'body' => new Order( - id: 1, - products: new Products(elements: [ - new Product(name: 'Product One', amount: new Amount(value: 100.50, currency: Currency::USD)), - new Product(name: 'Product Two', amount: new Amount(value: 200.75, currency: Currency::BRL)) - ]) - ), - 'expected' => json_encode([ - 'id' => 1, - 'products' => [ - [ - 'name' => 'Product One', - 'amount' => [ - 'value' => 100.50, - 'currency' => 'USD' - ] - ], - [ - 'name' => 'Product Two', - 'amount' => [ - 'value' => 200.75, - 'currency' => 'BRL' - ] - ] - ] - ], JSON_PRESERVE_ZERO_FRACTION) - ], - 'Boolean true value' => [ - 'body' => true, - 'expected' => 'true' - ], - 'Boolean false value' => [ - 'body' => false, - 'expected' => 'false' - ], - 'Large integer value' => [ - 'body' => PHP_INT_MAX, - 'expected' => (string)PHP_INT_MAX - ], - 'DateTimeInterface value' => [ - 'body' => new DateTime('2024-12-16'), - 'expected' => '[]' - ] - ]; - } -} diff --git a/tests/Unit/CapturingClient.php b/tests/Unit/CapturingClient.php new file mode 100644 index 0000000..b4e1272 --- /dev/null +++ b/tests/Unit/CapturingClient.php @@ -0,0 +1,31 @@ +createResponse($statusCode)); + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->captured = $request; + + return $this->response; + } +} diff --git a/tests/Unit/Client/RequestTest.php b/tests/Unit/Client/RequestTest.php new file mode 100644 index 0000000..bfa49ad --- /dev/null +++ b/tests/Unit/Client/RequestTest.php @@ -0,0 +1,243 @@ +url()); + self::assertSame(Method::GET, $request->method()); + self::assertNull($request->body()); + self::assertNull($request->query()); + self::assertSame([], $request->headers()->toArray()); + } + + public function testCreateWhenNullBodyGivenThenCarriesNoBody(): void + { + /** @When creating a request with an explicit null body */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @Then the body is null */ + self::assertNull($request->body()); + } + + public function testCreateWhenMultipleHeadersGivenThenMergesEntries(): void + { + /** @Given a Content-Type header with charset */ + $contentType = ContentType::applicationJson(charset: Charset::UTF_8); + + /** @And another Content-Type header without charset */ + $accept = ContentType::applicationJson(); + + /** @When creating a request with both headers merged */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::POST, + headers: Headers::from($contentType, $accept) + ); + + /** @Then the merged headers contain Content-Type */ + self::assertTrue($request->headers()->has('Content-Type')); + } + + public function testCreateWhenSameHeaderProvidedTwiceThenLastOneWins(): void + { + /** @Given a Content-Type header with charset */ + $first = ContentType::applicationJson(charset: Charset::UTF_8); + + /** @And another Content-Type header without charset */ + $second = ContentType::applicationJson(); + + /** @When creating the request with both (last one wins) */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::POST, + headers: Headers::from($first, $second) + ); + + /** @Then the last one wins for the Content-Type key */ + self::assertSame('application/json', $request->headers()->get('Content-Type')); + } + + public function testCreateWhenQueryGivenThenPreservesArrayInProperty(): void + { + /** @Given query parameters */ + $query = ['sort' => 'name', 'order' => 'asc']; + + /** @When creating the request with query */ + $request = Request::create( + url: '/dragons', + body: null, + query: $query, + method: Method::GET, + headers: Headers::from() + ); + + /** @Then the query is preserved */ + self::assertSame($query, $request->query()); + } + + public function testWithUrlWhenInvokedThenReturnsNewInstanceWithReplacedUrl(): void + { + /** @Given a request with an original URL */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @When calling withUrl */ + $updated = $request->withUrl(url: '/dragons/42'); + + /** @Then a new instance is returned with the URL replaced */ + self::assertNotSame($request, $updated); + self::assertSame('/dragons/42', $updated->url()); + self::assertSame('/dragons', $request->url()); + } + + public function testWithQueryWhenInvokedThenReturnsNewInstanceWithReplacedQuery(): void + { + /** @Given a request with an original query */ + $request = Request::create( + url: '/dragons', + body: null, + query: ['sort' => 'name'], + method: Method::GET, + headers: Headers::from() + ); + + /** @When calling withQuery */ + $updated = $request->withQuery(query: ['order' => 'asc']); + + /** @Then a new instance is returned with the query replaced */ + self::assertNotSame($request, $updated); + self::assertSame(['order' => 'asc'], $updated->query()); + self::assertSame(['sort' => 'name'], $request->query()); + } + + public function testCreateWhenDistinctKeyHeadersGivenThenBothPresent(): void + { + /** @Given a Content-Type header */ + $contentType = ContentType::applicationJson(); + + /** @And a Cache-Control header */ + $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::mustRevalidate()); + + /** @When creating a request with both headers */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from($contentType, $cacheControl) + ); + + /** @Then both header keys are present in the merged result */ + self::assertCount(2, $request->headers()->toArray()); + } + + public function testWithMergedHeadersWhenCustomConflictsWithDefaultThenCustomWins(): void + { + /** @Given a request with a custom Content-Type header */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::POST, + headers: Headers::from(ContentType::applicationJson(charset: Charset::UTF_8)) + ); + + /** @And defaults that include the same header */ + $defaults = new Headers(entries: ['Content-Type' => 'application/json', 'Accept' => 'application/json']); + + /** @When merging defaults under the existing headers */ + $resolved = $request->withMergedHeaders(defaults: $defaults); + + /** @Then the custom header wins over the default */ + self::assertSame('application/json; charset=utf-8', $resolved->headers()->get('Content-Type')); + self::assertSame('application/json', $resolved->headers()->get('Accept')); + } + + public function testHeadersWhenMixedCaseGivenThenLookupIsCaseInsensitive(): void + { + /** @Given a request with a Content-Type header */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from(ContentType::applicationJson()) + ); + + /** @When looking up the header with different casing */ + /** @Then the lookup succeeds regardless of case */ + self::assertTrue($request->headers()->has('content-type')); + self::assertSame('application/json', $request->headers()->get('CONTENT-TYPE')); + } + + public function testHeadersGetWhenMissingKeyGivenThenReturnsNull(): void + { + /** @Given a request with no headers */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @When looking up a non-existent header */ + /** @Then null is returned */ + self::assertNull($request->headers()->get('X-Missing')); + } + + public function testHeadersWhenRequestCreatedThenExposesHeadersInstance(): void + { + /** @Given a request */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @When accessing headers */ + /** @Then a Headers instance is returned */ + self::assertInstanceOf(Headers::class, $request->headers()); + } +} diff --git a/tests/Unit/Client/ResponseTest.php b/tests/Unit/Client/ResponseTest.php new file mode 100644 index 0000000..b16b3ef --- /dev/null +++ b/tests/Unit/Client/ResponseTest.php @@ -0,0 +1,244 @@ +factory = new Psr17Factory(); + } + + public function testFromWhen200JsonResponseGivenThenExposesTypedBody(): void + { + /** @Given a 200 response with a JSON body */ + $psrResponse = $this->factory->createResponse(200) + ->withBody($this->factory->createStream('{"id":42,"name":"Hydra"}')); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then typed body access works correctly */ + self::assertSame(42, $response->body()->get(key: 'id')->toInteger()); + self::assertSame('Hydra', $response->body()->get(key: 'name')->toString()); + self::assertSame(Code::OK, $response->code()); + } + + public function testFromWhen204ResponseGivenThenBodyIsEmptyArray(): void + { + /** @Given a 204 response with no body */ + $psrResponse = $this->factory->createResponse(204); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then the body array is empty */ + self::assertSame([], $response->body()->toArray()); + self::assertSame(Code::NO_CONTENT, $response->code()); + } + + public function testFromWhenNonJsonBodyGivenThenReturnsSafeEmptyArray(): void + { + /** @Given a 200 response with a non-JSON body */ + $psrResponse = $this->factory->createResponse(200) + ->withBody($this->factory->createStream('plain text')); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then the body gracefully returns an empty array */ + self::assertSame([], $response->body()->toArray()); + } + + public function testFromWhen200ResponseGivenThenIsSuccess(): void + { + /** @Given a 200 response */ + $psrResponse = $this->factory->createResponse(200); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then isSuccess is true */ + self::assertTrue($response->isSuccess()); + } + + public function testFromWhen200ResponseGivenThenIsNotError(): void + { + /** @Given a 200 response */ + $psrResponse = $this->factory->createResponse(200); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then isError is false */ + self::assertFalse($response->isError()); + } + + public function testFromWhen500ResponseGivenThenIsError(): void + { + /** @Given a 500 response */ + $psrResponse = $this->factory->createResponse(500); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then isError is true */ + self::assertTrue($response->isError()); + } + + public function testFromWhen500ResponseGivenThenIsNotSuccess(): void + { + /** @Given a 500 response */ + $psrResponse = $this->factory->createResponse(500); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then isSuccess is false */ + self::assertFalse($response->isSuccess()); + } + + public function testHeadersWhenPsrResponseGivenThenAccessibleViaHeadersValueObject(): void + { + /** @Given a response with two distinct headers */ + $psrResponse = $this->factory->createResponse(200) + ->withHeader('X-Trace', 'abc') + ->withHeader('X-Request-ID', '123'); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then headers() returns all headers accessible via the Headers value object */ + self::assertSame('abc', $response->headers()->get('X-Trace')); + self::assertSame('123', $response->headers()->get('X-Request-ID')); + } + + public function testRawWhenPsrResponseWrappedThenReturnsUnderlyingInstance(): void + { + /** @Given a PSR response */ + $psrResponse = $this->factory->createResponse(200); + + /** @When wrapping and then unwrapping */ + $response = Response::from(response: $psrResponse); + + /** @Then raw() returns the exact original instance */ + self::assertSame($psrResponse, $response->raw()); + } + + public function testWithWhenCodeAndBodyGivenThenSynthesizesAccessibleResponse(): void + { + /** @Given a status code and a body payload */ + $code = Code::CREATED; + + /** @And a body payload */ + $body = ['id' => 1]; + + /** @When synthesizing a response via with() */ + $response = Response::with(code: $code, body: $body); + + /** @Then code and body are accessible */ + self::assertSame(Code::CREATED, $response->code()); + self::assertSame(1, $response->body()->get(key: 'id')->toInteger()); + self::assertTrue($response->isSuccess()); + self::assertFalse($response->isError()); + } + + public function testRawWhenSynthesizedResponseGivenThenThrowsSynthesizedResponseHasNoRaw(): void + { + /** @Given a synthesized response */ + $response = Response::with(code: Code::OK); + + /** @Then SynthesizedResponseHasNoRaw is thrown with the documented message */ + $this->expectException(SynthesizedResponseHasNoRaw::class); + $this->expectExceptionMessage('Response was synthesized via Response::with(...)'); + + /** @When calling raw() */ + $response->raw(); + } + + public function testWithWhenNullBodyGivenThenReturnsEmptyArray(): void + { + /** @Given a status code with no body payload */ + $code = Code::NO_CONTENT; + + /** @When creating the response */ + $response = Response::with(code: $code); + + /** @Then body is empty */ + self::assertSame([], $response->body()->toArray()); + } + + public function testWithWhenHeadersGivenThenExposesViaHeadersAccessor(): void + { + /** @Given a Headers instance with one entry */ + $headers = new Headers(entries: ['X-Trace' => 'abc']); + + /** @When synthesizing a response with the headers */ + $response = Response::with(code: Code::OK, headers: $headers); + + /** @Then headers() returns the same value object */ + self::assertSame('abc', $response->headers()->get('X-Trace')); + } + + public function testFromWhenSeekableStreamGivenThenRawIsStillReadable(): void + { + /** @Given a 200 response with a JSON body in a seekable stream */ + $psrResponse = $this->factory->createResponse(200) + ->withBody($this->factory->createStream('{"name":"Hydra"}')); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then the body was parsed correctly */ + self::assertSame('Hydra', $response->body()->get(key: 'name')->toString()); + + /** @And the underlying stream is still readable via raw() */ + $raw = $response->raw()->getBody(); + $raw->rewind(); + self::assertSame('{"name":"Hydra"}', $raw->getContents()); + } + + public function testFromWhenAdvancedSeekableStreamGivenThenParsesBodyFromStart(): void + { + /** @Given a seekable stream advanced past its start */ + $stream = $this->factory->createStream('{"name":"Hydra"}'); + $stream->getContents(); + + /** @And a 200 response using that stream */ + $psrResponse = $this->factory->createResponse(200)->withBody($stream); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then the body is parsed correctly despite the advanced stream position */ + self::assertSame('Hydra', $response->body()->get(key: 'name')->toString()); + + /** @And the stream is at position zero after parsing so it can be re-read without a manual rewind */ + self::assertSame('{"name":"Hydra"}', $response->raw()->getBody()->getContents()); + } + + public function testFromWhenDeeplyNestedJsonGivenThenDegradesToEmptyArray(): void + { + /** @Given a JSON string nested deeper than 64 levels */ + $json = str_repeat('{"a":', 65) . '1' . str_repeat('}', 65); + $psrResponse = $this->factory->createResponse(200) + ->withBody($this->factory->createStream($json)); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then body degrades gracefully to an empty array */ + self::assertSame([], $response->body()->toArray()); + } +} diff --git a/tests/Unit/Client/Transports/InMemoryTransportTest.php b/tests/Unit/Client/Transports/InMemoryTransportTest.php new file mode 100644 index 0000000..34fdb50 --- /dev/null +++ b/tests/Unit/Client/Transports/InMemoryTransportTest.php @@ -0,0 +1,136 @@ +send(request: $request), + $transport->send(request: $request) + ]; + + /** @Then the drained sequence preserves FIFO order */ + self::assertSame(Code::OK, $drained[0]->code()); + self::assertSame(Code::CREATED, $drained[1]->code()); + } + + public function testSendWhenQueueExhaustedThenThrowsNoMoreResponses(): void + { + /** @Given a transport seeded with one response */ + $transport = InMemoryTransport::with(responses: [Response::with(code: Code::OK)]); + + /** @And a request to dispatch */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @And the seeded response is already consumed */ + $transport->send(request: $request); + + /** @Then NoMoreResponses is thrown on the next call */ + $this->expectException(NoMoreResponses::class); + + /** @When sending a second request */ + $transport->send(request: $request); + } + + public function testSendWhenQueueEmptyThenThrowsNoMoreResponsesImmediately(): void + { + /** @Given a transport seeded with zero responses */ + $transport = InMemoryTransport::with(responses: []); + + /** @And a request to dispatch */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @Then NoMoreResponses is thrown immediately */ + $this->expectException(NoMoreResponses::class); + + /** @When sending a request against the empty queue */ + $transport->send(request: $request); + } + + public function testSendWhenQueueEmptyThenExceptionMessageReferencesExhaustedIndex(): void + { + /** @Given a transport seeded with zero responses */ + $transport = InMemoryTransport::with(responses: []); + + /** @And a request to dispatch */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @Then the raised exception message references the exhausted index */ + $this->expectException(NoMoreResponses::class); + $this->expectExceptionMessage('InMemoryTransport has no response queued at index 0'); + + /** @When sending a request against the empty queue */ + $transport->send(request: $request); + } + + public function testSendWhenSingleResponseQueuedThenReturnsTheQueuedResponse(): void + { + /** @Given a transport seeded with a single CREATED response */ + $transport = InMemoryTransport::with(responses: [Response::with(code: Code::CREATED)]); + + /** @And a request to dispatch */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @When the request is sent */ + $response = $transport->send(request: $request); + + /** @Then the returned response carries the queued CREATED code */ + self::assertSame(Code::CREATED, $response->code()); + } +} diff --git a/tests/Unit/Client/Transports/NetworkTransportTest.php b/tests/Unit/Client/Transports/NetworkTransportTest.php new file mode 100644 index 0000000..bb45568 --- /dev/null +++ b/tests/Unit/Client/Transports/NetworkTransportTest.php @@ -0,0 +1,252 @@ +factory = new Psr17Factory(); + } + + public function testSendWhenBodyGivenThenForwardsJsonAndContentTypeHeader(): void + { + /** @Given a capturing client */ + $client = CapturingClient::returningStatus(statusCode: 201); + $transport = NetworkTransport::with(client: $client, factory: $this->factory); + + /** @When sending a request with a JSON body and a Content-Type default */ + $transport->send( + request: Request::create( + url: 'https://api.example.com/dragons', + body: ['name' => 'Hydra'], + query: null, + method: Method::POST, + headers: Headers::from() + )->withMergedHeaders(defaults: new Headers(entries: ['Content-Type' => 'application/json'])) + ); + + /** @Then the PSR-7 request carries JSON and the Content-Type header */ + self::assertNotNull($client->captured); + self::assertSame('{"name":"Hydra"}', (string)$client->captured->getBody()); + self::assertSame('application/json', $client->captured->getHeaderLine('Content-Type')); + } + + public function testSendWhenNoBodyGivenThenForwardsEmptyBody(): void + { + /** @Given a capturing client */ + $client = CapturingClient::returningStatus(statusCode: 200); + $transport = NetworkTransport::with(client: $client, factory: $this->factory); + + /** @When sending a request without body */ + $transport->send(request: Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); + + /** @Then the PSR-7 request body is empty */ + self::assertNotNull($client->captured); + self::assertSame('', (string)$client->captured->getBody()); + } + + public function testSendWhenCustomHeaderMergedThenForwardsToPsrRequest(): void + { + /** @Given a capturing client */ + $client = CapturingClient::returningStatus(statusCode: 200); + $transport = NetworkTransport::with(client: $client, factory: $this->factory); + + /** @When sending a request with a custom header merged in */ + $transport->send( + request: Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )->withMergedHeaders(defaults: new Headers(entries: ['X-Correlation-ID' => 'abc-123'])) + ); + + /** @Then the PSR-7 request carries the custom header */ + self::assertNotNull($client->captured); + self::assertSame('abc-123', $client->captured->getHeaderLine('X-Correlation-ID')); + } + + public function testSendWhenClientRaisesNetworkExceptionThenThrowsHttpNetworkFailed(): void + { + /** @Given a PSR-18 client that throws NetworkExceptionInterface */ + $transport = NetworkTransport::with( + client: ThrowingClient::throwing(exception: new PsrNetworkException('connection refused')), + factory: $this->factory + ); + + /** @Then HttpNetworkFailed is thrown */ + $this->expectException(HttpNetworkFailed::class); + + /** @When sending the request */ + $transport->send(request: Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); + } + + public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInvalid(): void + { + /** @Given a PSR-18 client that throws RequestExceptionInterface */ + $transport = NetworkTransport::with( + client: ThrowingClient::throwing(exception: new PsrRequestException('bad request')), + factory: $this->factory + ); + + /** @Then HttpRequestInvalid is thrown */ + $this->expectException(HttpRequestInvalid::class); + + /** @When sending the request */ + $transport->send(request: Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); + } + + public function testSendWhenClientRaisesGenericClientExceptionThenThrowsHttpRequestFailed(): void + { + /** @Given a PSR-18 client that throws a generic ClientExceptionInterface */ + $transport = NetworkTransport::with( + client: ThrowingClient::throwing(exception: new PsrClientException('generic failure')), + factory: $this->factory + ); + + /** @Then HttpRequestFailed is thrown */ + $this->expectException(HttpRequestFailed::class); + + /** @When sending the request */ + $transport->send(request: Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); + } + + public function testSendWhenClientRaisesRequestExceptionThenExceptionMessageDescribesInvalidRequest(): void + { + /** @Given a transport whose client throws RequestExceptionInterface */ + $transport = NetworkTransport::with( + client: ThrowingClient::throwing(exception: new PsrRequestException('bad request')), + factory: $this->factory + ); + + try { + /** @When sending the request */ + $transport->send(request: Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::POST, + headers: Headers::from() + )); + self::fail('HttpRequestInvalid was expected.'); + } catch (HttpRequestInvalid $exception) { + /** @Then the message names the method, the URL, and the client-supplied reason */ + self::assertStringContainsString('POST', $exception->getMessage()); + self::assertStringContainsString('https://api.example.com/dragons', $exception->getMessage()); + self::assertStringContainsString('bad request', $exception->getMessage()); + } + } + + public function testSendWhenClientRaisesGenericClientExceptionThenExceptionMessageDescribesClientFailure(): void + { + /** @Given a transport whose client throws a generic ClientExceptionInterface */ + $transport = NetworkTransport::with( + client: ThrowingClient::throwing(exception: new PsrClientException('generic failure')), + factory: $this->factory + ); + + try { + /** @When sending the request */ + $transport->send(request: Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::DELETE, + headers: Headers::from() + )); + self::fail('HttpRequestFailed was expected.'); + } catch (HttpRequestFailed $exception) { + /** @Then the message names the method, the URL, and the client-supplied reason */ + self::assertStringContainsString('DELETE', $exception->getMessage()); + self::assertStringContainsString('https://api.example.com/dragons', $exception->getMessage()); + self::assertStringContainsString('generic failure', $exception->getMessage()); + } + } + + public function testSendWhenSuccessfulPsrResponseGivenThenWrapsInClientResponse(): void + { + /** @Given a client that returns a 200 response */ + $client = CapturingClient::returningStatus(statusCode: 200); + $transport = NetworkTransport::with(client: $client, factory: $this->factory); + + /** @When sending a request */ + $response = $transport->send(request: Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); + + /** @Then the response code is correct */ + self::assertSame(Code::OK, $response->code()); + } + + public function testSendWhenBodyHasInvalidUtf8ThenSubstitutesAndStillSends(): void + { + /** @Given a transport configured with a capturing client */ + $client = CapturingClient::returningStatus(statusCode: 200); + $transport = NetworkTransport::with(client: $client, factory: $this->factory); + + /** @When sending a request whose body contains a non-UTF-8 byte sequence */ + $transport->send( + request: Request::create( + url: 'https://api.example.com/dragons', + body: ['value' => "\xB0\xB1\xB2"], + query: null, + method: Method::POST, + headers: Headers::from() + ) + ); + + /** @Then the PSR-7 request body carries the JSON-escaped replacement character */ + self::assertNotNull($client->captured); + self::assertStringContainsString('\ufffd', (string)$client->captured->getBody()); + } +} diff --git a/tests/CodeTest.php b/tests/Unit/CodeTest.php similarity index 61% rename from tests/CodeTest.php rename to tests/Unit/CodeTest.php index 4e7b6ed..5b8e713 100644 --- a/tests/CodeTest.php +++ b/tests/Unit/CodeTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http; +namespace Test\TinyBlocks\Http\Unit; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -11,49 +11,97 @@ final class CodeTest extends TestCase { #[DataProvider('messagesDataProvider')] - public function testMessage(Code $code, string $expected): void + public function testMessageWhenKnownCodeGivenThenReturnsRfcDescription(Code $code, string $expected): void { /** @Given a Code instance */ /** @When retrieving the message for the Code */ $actual = $code->message(); - /** @Then the message should match the expected string */ + /** @Then the message matches the expected string */ self::assertSame($expected, $actual); } #[DataProvider('codesDataProvider')] - public function testIsHttpCode(int $code, bool $expected): void + public function testIsValidCodeWhenIntegerGivenThenReturnsExpected(int $code, bool $expected): void { /** @Given an integer representing an HTTP code */ /** @When checking if it is a valid HTTP code */ $actual = Code::isValidCode(code: $code); - /** @Then the result should match the expected boolean */ + /** @Then the result matches the expected boolean */ self::assertSame($expected, $actual); } #[DataProvider('errorCodesDataProvider')] - public function testIsErrorCode(int $code, bool $expected): void + public function testIsErrorCodeWhenIntegerGivenThenReturnsExpected(int $code, bool $expected): void { /** @Given an HTTP status code */ /** @When checking if it is an error code (4xx or 5xx) */ $actual = Code::isErrorCode(code: $code); - /** @Then the result should match the expected boolean */ + /** @Then the result matches the expected boolean */ self::assertSame($expected, $actual); } #[DataProvider('successCodesDataProvider')] - public function testIsSuccessCode(int $code, bool $expected): void + public function testIsSuccessCodeWhenIntegerGivenThenReturnsExpected(int $code, bool $expected): void { /** @Given an HTTP status code */ /** @When checking if it is a success code (2xx) */ $actual = Code::isSuccessCode(code: $code); - /** @Then the result should match the expected boolean */ + /** @Then the result matches the expected boolean */ self::assertSame($expected, $actual); } + public function testIsSuccessWhenCodeOkGivenThenReturnsTrue(): void + { + /** @Given Code::OK */ + $code = Code::OK; + + /** @When invoking isSuccess */ + $actual = $code->isSuccess(); + + /** @Then the result is true */ + self::assertTrue($actual); + } + + public function testIsErrorWhenCodeOkGivenThenReturnsFalse(): void + { + /** @Given Code::OK */ + $code = Code::OK; + + /** @When invoking isError */ + $actual = $code->isError(); + + /** @Then the result is false */ + self::assertFalse($actual); + } + + public function testIsErrorWhenCodeInternalServerErrorGivenThenReturnsTrue(): void + { + /** @Given Code::INTERNAL_SERVER_ERROR */ + $code = Code::INTERNAL_SERVER_ERROR; + + /** @When invoking isError */ + $actual = $code->isError(); + + /** @Then the result is true */ + self::assertTrue($actual); + } + + public function testIsSuccessWhenCodeInternalServerErrorGivenThenReturnsFalse(): void + { + /** @Given Code::INTERNAL_SERVER_ERROR */ + $code = Code::INTERNAL_SERVER_ERROR; + + /** @When invoking isSuccess */ + $actual = $code->isSuccess(); + + /** @Then the result is false */ + self::assertFalse($actual); + } + public static function messagesDataProvider(): array { return [ @@ -103,30 +151,12 @@ public static function messagesDataProvider(): array public static function codesDataProvider(): array { return [ - 'Invalid code 0' => [ - 'code' => 0, - 'expected' => false - ], - 'Invalid code -1' => [ - 'code' => -1, - 'expected' => false - ], - 'Invalid code 1054' => [ - 'code' => 1054, - 'expected' => false - ], - 'Valid code 200 OK' => [ - 'code' => Code::OK->value, - 'expected' => true - ], - 'Valid code 100 Continue' => [ - 'code' => Code::CONTINUE->value, - 'expected' => true - ], - 'Valid code 500 Internal Server Error' => [ - 'code' => Code::INTERNAL_SERVER_ERROR->value, - 'expected' => true - ] + 'Invalid code 0' => ['code' => 0, 'expected' => false], + 'Invalid code -1' => ['code' => -1, 'expected' => false], + 'Invalid code 1054' => ['code' => 1054, 'expected' => false], + 'Valid code 200 OK' => ['code' => Code::OK->value, 'expected' => true], + 'Valid code 100 Continue' => ['code' => Code::CONTINUE->value, 'expected' => true], + 'Valid code 500 Internal Server Error' => ['code' => Code::INTERNAL_SERVER_ERROR->value, 'expected' => true] ]; } diff --git a/tests/CookieTest.php b/tests/Unit/CookieTest.php similarity index 60% rename from tests/CookieTest.php rename to tests/Unit/CookieTest.php index 1576155..3bdd14e 100644 --- a/tests/CookieTest.php +++ b/tests/Unit/CookieTest.php @@ -2,22 +2,20 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http; +namespace Test\TinyBlocks\Http\Unit; use DateTimeImmutable; use DateTimeZone; +use DomainException; +use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use TinyBlocks\Http\Cookie; -use TinyBlocks\Http\Internal\Exceptions\ConflictingLifetimeAttributes; -use TinyBlocks\Http\Internal\Exceptions\CookieNameIsInvalid; -use TinyBlocks\Http\Internal\Exceptions\CookieValueIsInvalid; -use TinyBlocks\Http\Internal\Exceptions\SameSiteNoneRequiresSecure; use TinyBlocks\Http\SameSite; final class CookieTest extends TestCase { - public function testCreateCookieWithNameAndValue(): void + public function testCreateWhenNameAndValueGivenThenSerializesNameValuePair(): void { /** @Given a cookie name and value */ $cookie = Cookie::create(name: 'session', value: 'abc'); @@ -25,59 +23,68 @@ public function testCreateCookieWithNameAndValue(): void /** @When the header is serialized */ $actual = $cookie->toArray(); - /** @Then the header should contain only the name and value */ + /** @Then the header contains only the name and value */ self::assertSame(['Set-Cookie' => ['session=abc']], $actual); } - public function testCreateCookieWithAllAttributes(): void + public function testCreateWhenAllAttributesAppliedThenSerializesInCanonicalOrder(): void { /** @Given a cookie composed with every supported attribute */ $cookie = Cookie::create(name: 'refresh_token', value: 'opaque-value') - ->withMaxAge(seconds: 604800) - ->withPath(path: '/v1/sessions') - ->withDomain(domain: 'api.example.com') ->secure() ->httpOnly() - ->withSameSite(sameSite: SameSite::STRICT) - ->partitioned(); + ->withPath(path: '/v1/sessions') + ->withMaxAge(seconds: 604800) + ->withDomain(domain: 'api.example.com') + ->partitioned() + ->withSameSite(sameSite: SameSite::STRICT); /** @When the header is serialized */ $actual = $cookie->toArray(); - /** @Then the header should include every attribute in the canonical order */ + /** @Then the header includes every attribute in the canonical order */ $expected = 'refresh_token=opaque-value; Max-Age=604800; Path=/v1/sessions; ' . 'Domain=api.example.com; Secure; HttpOnly; SameSite=Strict; Partitioned'; self::assertSame(['Set-Cookie' => [$expected]], $actual); } - public function testExpireCookieEmitsEmptyValueAndMaxAgeZero(): void + public function testExpireWhenInvokedThenEmitsEmptyValueAndMaxAgeZero(): void { - /** @Given a cookie deletion for an existing name */ - /** @And the same path used when the cookie was issued */ + /** @Given a cookie deletion bound to the path used when the cookie was issued */ $cookie = Cookie::expire(name: 'refresh_token')->withPath(path: '/v1/sessions'); /** @When the header is serialized */ $actual = $cookie->toArray(); - /** @Then the header should instruct the browser to discard the cookie */ + /** @Then the header instructs the browser to discard the cookie */ self::assertSame(['Set-Cookie' => ['refresh_token=; Max-Age=0; Path=/v1/sessions']], $actual); } - public function testWithValueReturnsNewInstanceWithReplacedValue(): void + public function testWithValueWhenInvokedThenLeavesOriginalUntouched(): void { /** @Given a cookie with an initial value */ $original = Cookie::create(name: 'session', value: 'initial'); /** @When a new value is assigned */ - $rotated = $original->withValue(value: 'rotated'); + $original->withValue(value: 'rotated'); /** @Then the original instance remains unchanged */ self::assertSame(['Set-Cookie' => ['session=initial']], $original->toArray()); - /** @And the new instance carries the replaced value */ + } + + public function testWithValueWhenInvokedThenReturnsNewInstanceWithReplacedValue(): void + { + /** @Given a cookie with an initial value */ + $original = Cookie::create(name: 'session', value: 'initial'); + + /** @When a new value is assigned */ + $rotated = $original->withValue(value: 'rotated'); + + /** @Then the new instance carries the replaced value */ self::assertSame(['Set-Cookie' => ['session=rotated']], $rotated->toArray()); } - public function testWithExpiresRendersTheDateInRfcFormatInUtc(): void + public function testWithExpiresWhenNonUtcDateGivenThenRendersInUtcRfcFormat(): void { /** @Given an expiration in a non-UTC timezone */ $cookie = Cookie::create(name: 'session', value: 'abc')->withExpires( @@ -87,41 +94,51 @@ public function testWithExpiresRendersTheDateInRfcFormatInUtc(): void /** @When the header is serialized */ $actual = $cookie->toArray(); - /** @Then the Expires attribute should be converted to UTC and formatted per RFC 7231 */ + /** @Then the Expires attribute is converted to UTC and formatted per RFC 7231 */ self::assertSame( ['Set-Cookie' => ['session=abc; Expires=Tue, 15 Jan 2030 15:00:00 GMT']], $actual ); } - public function testBuilderMethodsReturnNewInstanceWithoutMutatingOriginal(): void + public function testSecureWhenInvokedThenLeavesBaseUntouched(): void { /** @Given a base cookie without the secure flag */ $base = Cookie::create(name: 'session', value: 'abc'); /** @When the secure flag is applied */ - $secured = $base->secure(); + $base->secure(); /** @Then the base instance remains unchanged */ self::assertSame(['Set-Cookie' => ['session=abc']], $base->toArray()); - /** @And the new instance has the secure flag applied */ + } + + public function testSecureWhenInvokedThenReturnsNewInstanceWithFlag(): void + { + /** @Given a base cookie without the secure flag */ + $base = Cookie::create(name: 'session', value: 'abc'); + + /** @When the secure flag is applied */ + $secured = $base->secure(); + + /** @Then the new instance has the secure flag applied */ self::assertSame(['Set-Cookie' => ['session=abc; Secure']], $secured->toArray()); } - public function testSameSiteNoneWithoutSecureThrows(): void + public function testToArrayWhenSameSiteNoneWithoutSecureGivenThenThrows(): void { /** @Given a cookie set to SameSite=None without the Secure flag */ $cookie = Cookie::create(name: 'session', value: 'abc')->withSameSite(sameSite: SameSite::NONE); - /** @Then an exception indicating the missing Secure flag should be thrown */ - $this->expectException(SameSiteNoneRequiresSecure::class); - $this->expectExceptionMessage('Cookies with SameSite=None require the Secure flag to be set; modern browsers reject such cookies otherwise. Call secure() on the Cookie instance.'); + /** @Then an exception indicating the missing Secure flag is thrown */ + $this->expectException(DomainException::class); + $this->expectExceptionMessage('SameSite=None require the Secure flag'); /** @When the header is serialized */ $cookie->toArray(); } - public function testSameSiteNoneWithSecureIsAllowed(): void + public function testToArrayWhenSameSiteNoneWithSecureGivenThenSerializesBothAttributes(): void { /** @Given a cookie with SameSite=None combined with Secure */ $cookie = Cookie::create(name: 'session', value: 'abc') @@ -131,26 +148,26 @@ public function testSameSiteNoneWithSecureIsAllowed(): void /** @When the header is serialized */ $actual = $cookie->toArray(); - /** @Then both attributes should be present */ + /** @Then both attributes are present */ self::assertSame(['Set-Cookie' => ['session=abc; Secure; SameSite=None']], $actual); } - public function testMaxAgeAndExpiresTogetherThrows(): void + public function testToArrayWhenBothMaxAgeAndExpiresGivenThenThrows(): void { /** @Given a cookie with both Max-Age and Expires assigned */ $cookie = Cookie::create(name: 'session', value: 'abc') ->withMaxAge(seconds: 3600) ->withExpires(expires: new DateTimeImmutable('2030-01-15 12:00:00 UTC')); - /** @Then an exception indicating conflicting lifetime attributes should be thrown */ - $this->expectException(ConflictingLifetimeAttributes::class); - $this->expectExceptionMessage('Cookie lifetime attributes are conflicting. A cookie must declare its lifetime via either Max-Age or Expires, not both. Choose one and reset the other with a new Cookie instance.'); + /** @Then an exception indicating conflicting lifetime attributes is thrown */ + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Cookie lifetime attributes are conflicting'); /** @When the header is serialized */ $cookie->toArray(); } - public function testEmptyValueIsAcceptedAsValid(): void + public function testCreateWhenEmptyValueGivenThenRendersEmpty(): void { /** @Given an empty value */ $cookie = Cookie::create(name: 'session', value: ''); @@ -158,59 +175,65 @@ public function testEmptyValueIsAcceptedAsValid(): void /** @When the header is serialized */ $actual = $cookie->toArray(); - /** @Then the value should be rendered as empty */ + /** @Then the value is rendered as empty */ self::assertSame(['Set-Cookie' => ['session=']], $actual); } - public function testWithValueRejectsInvalidReplacement(): void + public function testWithValueWhenForbiddenCharacterGivenThenThrows(): void { /** @Given a valid cookie */ $cookie = Cookie::create(name: 'session', value: 'abc'); - /** @Then an exception indicating the value is invalid should be thrown */ - $this->expectException(CookieValueIsInvalid::class); + /** @Then an exception indicating the value is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cookie value is invalid'); /** @When the value is replaced with one containing forbidden characters */ $cookie->withValue(value: 'has;semicolon'); } - public function testExpireValidatesTheName(): void + public function testExpireWhenInvalidNameGivenThenThrows(): void { - /** @Then an exception indicating the name is invalid should be thrown */ - $this->expectException(CookieNameIsInvalid::class); - $this->expectExceptionMessage('Cookie name is invalid. A name must not be empty and must not contain control characters, whitespace, or any of the following separators: ( ) < > @ , ; : \\ " / [ ] ? = { }.'); + /** @Then an exception indicating the name is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cookie name is invalid'); /** @When expiring a cookie with an invalid name */ Cookie::expire(name: 'bad name'); } - public function testCreateExposesInvalidValueMessage(): void + public function testCreateWhenForbiddenCharacterInValueGivenThenThrows(): void { - /** @Then an exception indicating the value is invalid should be thrown */ - $this->expectException(CookieValueIsInvalid::class); - $this->expectExceptionMessage('Cookie value is invalid. A value must not contain control characters, whitespace, double quotes, commas, semicolons, or backslashes. Encode the value (e.g., URL-encode or Base64) before passing it.'); + /** @Then an exception indicating the value is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); /** @When creating a cookie with the invalid value */ Cookie::create(name: 'session', value: 'abc;def'); } + /** + * @Given an invalid cookie name + * @When Cookie::create is called with that name + * @Then it throws CookieNameIsInvalid + */ #[DataProvider('invalidNameProvider')] - public function testCreateCookieRejectsInvalidName(string $name): void + public function testCreateWhenInvalidNameGivenThenThrows(string $name): void { - /** @Then an exception indicating the name is invalid should be thrown */ - $this->expectException(CookieNameIsInvalid::class); + $this->expectException(InvalidArgumentException::class); - /** @When creating a cookie with the invalid name */ Cookie::create(name: $name, value: 'value'); } + /** + * @Given an invalid cookie value + * @When Cookie::create is called with that value + * @Then it throws CookieValueIsInvalid + */ #[DataProvider('invalidValueProvider')] - public function testCreateCookieRejectsInvalidValue(string $value): void + public function testCreateWhenInvalidValueGivenThenThrows(string $value): void { - /** @Then an exception indicating the value is invalid should be thrown */ - $this->expectException(CookieValueIsInvalid::class); + $this->expectException(InvalidArgumentException::class); - /** @When creating a cookie with the invalid value */ Cookie::create(name: 'session', value: $value); } @@ -224,7 +247,7 @@ public static function invalidNameProvider(): array 'Name with control character' => ["session\x00"], 'Name with comma' => ['session,id'], 'Name with double quote' => ['session"'], - 'Name with brackets' => ['session[]'], + 'Name with brackets' => ['session[]'] ]; } @@ -237,7 +260,7 @@ public static function invalidValueProvider(): array 'Value with comma' => ['abc,def'], 'Value with double quote' => ['abc"def'], 'Value with backslash' => ['abc\\def'], - 'Value with control character' => ["abc\x00def"], + 'Value with control character' => ["abc\x00def"] ]; } } diff --git a/tests/Unit/FailingTransport.php b/tests/Unit/FailingTransport.php new file mode 100644 index 0000000..df9208b --- /dev/null +++ b/tests/Unit/FailingTransport.php @@ -0,0 +1,63 @@ + HttpNetworkFailed::from( + url: $request->url(), + method: $request->method(), + reason: $reason, + previous: $cause + ); + + return new FailingTransport(factory: $factory); + } + + public static function raisingRequestInvalid(string $reason, RuntimeException $cause): FailingTransport + { + $factory = static fn(Request $request): HttpException => HttpRequestInvalid::from( + url: $request->url(), + method: $request->method(), + reason: $reason, + previous: $cause + ); + + return new FailingTransport(factory: $factory); + } + + public static function raisingRequestFailure(string $reason, RuntimeException $cause): FailingTransport + { + $factory = static fn(Request $request): HttpException => HttpRequestFailed::from( + url: $request->url(), + method: $request->method(), + reason: $reason, + previous: $cause + ); + + return new FailingTransport(factory: $factory); + } + + public function send(Request $request): Response + { + throw ($this->factory)($request); + } +} diff --git a/tests/Unit/HeadersTest.php b/tests/Unit/HeadersTest.php new file mode 100644 index 0000000..8f53d6c --- /dev/null +++ b/tests/Unit/HeadersTest.php @@ -0,0 +1,225 @@ + 'application/json', 'Accept' => 'application/json']; + + /** @When creating Headers from a constructor */ + $headers = new Headers(entries: $entries); + + /** @Then the entries are accessible */ + self::assertSame('application/json', $headers->get('Content-Type')); + self::assertSame('application/json', $headers->get('Accept')); + } + + public function testFromWhenMultipleHeaderablesGivenThenMergesEntries(): void + { + /** @Given a Content-Type headerable */ + $contentType = ContentType::applicationJson(charset: Charset::UTF_8); + + /** @And a Cookie headerable */ + $cookie = Cookie::create(name: 'session', value: 'abc123'); + + /** @When creating Headers from multiple headerables */ + $headers = Headers::from($contentType, $cookie); + + /** @Then both header entries are present */ + self::assertTrue($headers->has('Content-Type')); + self::assertTrue($headers->has('Set-Cookie')); + } + + public function testFromWhenNoArgumentsGivenThenReturnsEmptyHeaders(): void + { + /** @When creating Headers with no headerable arguments */ + $headers = Headers::from(); + + /** @Then the headers are empty */ + self::assertSame([], $headers->toArray()); + } + + public function testFromMessageWhenEmptyHeadersGivenThenReturnsEmptyHeaders(): void + { + /** @Given a PSR-7 response with no headers */ + $psrResponse = new Psr17Factory()->createResponse(200); + + /** @When building Headers from the message */ + $headers = Headers::fromMessage(message: $psrResponse); + + /** @Then the Headers instance is empty */ + self::assertSame([], $headers->toArray()); + } + + public function testFromMessageWhenMultiValueHeaderGivenThenFoldsWithComma(): void + { + /** @Given a PSR-7 response with a header that carries multiple values */ + $psrResponse = new Psr17Factory()->createResponse(200) + ->withHeader('Accept', 'application/json') + ->withAddedHeader('Accept', 'text/html'); + + /** @When building Headers from the message */ + $headers = Headers::fromMessage(message: $psrResponse); + + /** @Then the multi-value header is folded with a comma separator */ + self::assertSame('application/json, text/html', $headers->get('Accept')); + } + + public function testApplyToWhenEmptyHeadersGivenThenReturnsMessageUnchanged(): void + { + /** @Given an empty Headers instance */ + $headers = new Headers(entries: []); + + /** @And a PSR-7 request */ + $psrRequest = new Psr17Factory()->createRequest('GET', 'https://api.example.com'); + + /** @When applying the empty headers to the request */ + $applied = $headers->applyTo(message: $psrRequest); + + /** @Then the same request instance is returned without modification */ + self::assertSame($psrRequest, $applied); + } + + public function testApplyToWhenEntriesGivenThenAttachesHeaders(): void + { + /** @Given a Headers instance with one entry */ + $headers = new Headers(entries: ['X-Trace' => 'abc']); + + /** @And a PSR-7 request */ + $psrRequest = new Psr17Factory()->createRequest('GET', 'https://api.example.com'); + + /** @When applying the headers to the request */ + $applied = $headers->applyTo(message: $psrRequest); + + /** @Then the resulting message carries the header */ + self::assertSame('abc', $applied->getHeaderLine('X-Trace')); + } + + public function testApplyToWhenEntriesGivenThenLeavesOriginalUnchanged(): void + { + /** @Given a Headers instance with one entry */ + $headers = new Headers(entries: ['X-Trace' => 'abc']); + + /** @And a PSR-7 request */ + $psrRequest = new Psr17Factory()->createRequest('GET', 'https://api.example.com'); + + /** @When applying the headers to the request */ + $headers->applyTo(message: $psrRequest); + + /** @Then the original request is unchanged */ + self::assertSame('', $psrRequest->getHeaderLine('X-Trace')); + } + + public function testGetWhenMixedCaseKeyGivenThenLookupIsCaseInsensitive(): void + { + /** @Given headers with a mixed-case key */ + $headers = new Headers(entries: ['Content-Type' => 'application/json']); + + /** @When looking up with different casing */ + /** @Then the lookup succeeds */ + self::assertSame('application/json', $headers->get('content-type')); + self::assertSame('application/json', $headers->get('CONTENT-TYPE')); + self::assertSame('application/json', $headers->get('Content-Type')); + } + + public function testGetWhenMissingKeyGivenThenReturnsNull(): void + { + /** @Given headers with one entry */ + $headers = new Headers(entries: ['Content-Type' => 'application/json']); + + /** @When looking up a non-existent header */ + /** @Then null is returned */ + self::assertNull($headers->get('X-Missing')); + } + + public function testHasWhenMixedCaseKeyGivenThenIsCaseInsensitive(): void + { + /** @Given headers with a mixed-case key */ + $headers = new Headers(entries: ['X-Trace' => 'abc']); + + /** @When checking existence with different casing */ + /** @Then has() returns true regardless of case */ + self::assertTrue($headers->has('x-trace')); + self::assertTrue($headers->has('X-TRACE')); + self::assertTrue($headers->has('X-Trace')); + } + + public function testHasWhenMissingKeyGivenThenReturnsFalse(): void + { + /** @Given empty headers */ + $headers = new Headers(entries: []); + + /** @When checking for a non-existent header */ + /** @Then has() returns false */ + self::assertFalse($headers->has('Content-Type')); + } + + public function testMergedWithWhenOtherHasNewEntriesThenBothAppearInResult(): void + { + /** @Given headers with one entry */ + $headers = new Headers(entries: ['Accept' => 'application/json']); + + /** @When merging with a Headers carrying a default that does not conflict */ + $merged = $headers->mergedWith(other: new Headers(entries: ['Content-Type' => 'application/json'])); + + /** @Then both entries are present */ + self::assertSame('application/json', $merged->get('Accept')); + self::assertSame('application/json', $merged->get('Content-Type')); + } + + public function testMergedWithWhenOtherCollidesThenExistingEntryWins(): void + { + /** @Given headers with a Content-Type entry */ + $headers = new Headers(entries: ['Content-Type' => 'application/json; charset=utf-8']); + + /** @When merging with a Headers carrying a default Content-Type */ + $merged = $headers->mergedWith(other: new Headers(entries: ['Content-Type' => 'application/json'])); + + /** @Then the existing header wins */ + self::assertSame('application/json; charset=utf-8', $merged->get('Content-Type')); + + /** @And only one Content-Type entry exists in the merged result */ + self::assertCount(1, $merged->toArray()); + } + + public function testMergedWithWhenCasingDiffersThenStillTreatsAsCollision(): void + { + /** @Given headers with a lowercase key */ + $headers = new Headers(entries: ['content-type' => 'application/json; charset=utf-8']); + + /** @When merging with a Headers using mixed casing */ + $merged = $headers->mergedWith(other: new Headers(entries: ['Content-Type' => 'application/json'])); + + /** @Then the existing header wins despite different casing */ + self::assertSame('application/json; charset=utf-8', $merged->get('content-type')); + + /** @And only one Content-Type entry exists in the merged result */ + self::assertCount(1, $merged->toArray()); + } + + public function testToArrayWhenMultipleEntriesGivenThenReturnsAll(): void + { + /** @Given headers with two entries */ + $headers = new Headers(entries: ['X-Trace' => 'abc', 'X-Request-ID' => '123']); + + /** @When converting to array */ + $array = $headers->toArray(); + + /** @Then all entries are present */ + self::assertSame('abc', $array['X-Trace']); + self::assertSame('123', $array['X-Request-ID']); + self::assertCount(2, $array); + } +} diff --git a/tests/Unit/HttpBuilderTest.php b/tests/Unit/HttpBuilderTest.php new file mode 100644 index 0000000..69e2c4b --- /dev/null +++ b/tests/Unit/HttpBuilderTest.php @@ -0,0 +1,169 @@ +withTransport(transport: $transport); + + /** @Then a new builder instance is returned */ + self::assertNotSame($original, $updated); + } + + public function testWithTransportWhenInvokedThenOriginalBuilderStillThrows(): void + { + /** @Given an empty builder */ + $original = Http::create(); + + /** @And a fresh transport */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: new Psr17Factory() + ); + + /** @And the original builder receives a new transport */ + $original->withTransport(transport: $transport); + + /** @Then the original builder still throws on build */ + $this->expectException(HttpConfigurationInvalid::class); + + /** @When calling build on the original builder */ + $original->build(); + } + + public function testWithBaseUrlWhenInvokedThenReturnsNewBuilder(): void + { + /** @Given an empty builder */ + $original = Http::create(); + + /** @When calling withBaseUrl */ + $updated = $original->withBaseUrl(url: 'https://api.example.com'); + + /** @Then a new builder instance is returned */ + self::assertNotSame($original, $updated); + } + + public function testWithBaseUrlWhenInvokedThenOriginalBuilderStillThrows(): void + { + /** @Given an empty builder */ + $original = Http::create(); + + /** @And the original builder receives a new base URL */ + $original->withBaseUrl(url: 'https://api.example.com'); + + /** @Then the original builder still throws on build */ + $this->expectException(HttpConfigurationInvalid::class); + + /** @When calling build on the original builder */ + $original->build(); + } + + public function testBuildWhenTransportMissingThenThrowsHttpConfigurationInvalid(): void + { + /** @Given a builder with no transport */ + $builder = Http::create()->withBaseUrl(url: 'https://api.example.com'); + + /** @Then HttpConfigurationInvalid is thrown */ + $this->expectException(HttpConfigurationInvalid::class); + $this->expectExceptionMessage('Transport is required to build Http.'); + + /** @When calling build */ + $builder->build(); + } + + public function testBuildWhenBaseUrlMissingThenThrowsHttpConfigurationInvalid(): void + { + /** @Given a builder with no base URL */ + $builder = Http::create()->withTransport( + transport: InMemoryTransport::with(responses: []) + ); + + /** @Then HttpConfigurationInvalid is thrown */ + $this->expectException(HttpConfigurationInvalid::class); + $this->expectExceptionMessage('Base URL is required to build Http.'); + + /** @When calling build */ + $builder->build(); + } + + public function testBuildWhenFullyConfiguredThenProducesWorkingHttp(): void + { + /** @Given a transport seeded with one response */ + $transport = InMemoryTransport::with(responses: [Response::with(code: Code::OK)]); + + /** @And a fully configured builder */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: $transport) + ->build(); + + /** @When sending a request */ + $response = $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); + + /** @Then the response is returned correctly */ + self::assertSame(Code::OK, $response->code()); + } + + public function testWithWhenInvokedDirectlyThenReturnsWorkingHttp(): void + { + /** @Given a transport seeded with one response */ + $transport = InMemoryTransport::with(responses: [Response::with(code: Code::OK)]); + + /** @When constructing Http directly via Http::with */ + $http = Http::with(baseUrl: 'https://api.example.com', transport: $transport); + + /** @And a simple GET request */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @Then the instance can send requests and returns the correct response */ + self::assertSame(Code::OK, $http->send(request: $request)->code()); + } +} diff --git a/tests/Unit/HttpTest.php b/tests/Unit/HttpTest.php new file mode 100644 index 0000000..a22022f --- /dev/null +++ b/tests/Unit/HttpTest.php @@ -0,0 +1,615 @@ +factory = new Psr17Factory(); + } + + public function testSendWhenTransportRespondsThenReturnsResponseWithMatchingCode(): void + { + /** @Given a transport seeded with a 200 response */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) + ->build(); + + /** @When sending a valid request */ + $response = $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); + + /** @Then the response code is correct */ + self::assertSame(Code::OK, $response->code()); + } + + public function testSendWhenBaseUrlEndsWithSlashAndPathLeadsWithSlashThenNoDoubleSlash(): void + { + /** @Given a transport seeded with a 200 response and a base URL ending in slash */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com/') + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) + ->build(); + + /** @When sending a request whose path starts with a slash */ + $response = $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); + + /** @Then the response is returned without double slash in the URL */ + self::assertSame(Code::OK, $response->code()); + } + + public function testSendWhenQueryGivenThenAppendsAsRfc3986(): void + { + /** @Given a transport seeded with a 200 response */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) + ->build(); + + /** @When sending a request with query parameters */ + $response = $http->send( + request: Request::create( + url: '/dragons', + body: null, + query: ['sort' => 'name', 'order' => 'asc'], + method: Method::GET, + headers: Headers::from() + ) + ); + + /** @Then the response code is correct */ + self::assertSame(Code::OK, $response->code()); + } + + public function testSendWhenBodyGivenThenSendsJsonPayload(): void + { + /** @Given a transport seeded with a 201 response */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 201), + factory: $this->factory + )) + ->build(); + + /** @When sending a request with a JSON body */ + $response = $http->send( + request: Request::create( + url: '/dragons', + body: ['name' => 'Hydra'], + query: null, + method: Method::POST, + headers: Headers::from() + ) + ); + + /** @Then the response code is correct */ + self::assertSame(Code::CREATED, $response->code()); + } + + public function testSendWhenClientRaisesNetworkExceptionThenThrowsHttpNetworkFailed(): void + { + /** @Given a PSR-18 client that throws NetworkExceptionInterface */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with( + client: ThrowingClient::throwing(exception: new PsrNetworkException('connection refused')), + factory: $this->factory + )) + ->build(); + + /** @Then HttpNetworkFailed is thrown */ + $this->expectException(HttpNetworkFailed::class); + + /** @When sending the request */ + $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); + } + + public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInvalid(): void + { + /** @Given a PSR-18 client that throws RequestExceptionInterface */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with( + client: ThrowingClient::throwing(exception: new PsrRequestException('bad request')), + factory: $this->factory + )) + ->build(); + + /** @Then HttpRequestInvalid is thrown */ + $this->expectException(HttpRequestInvalid::class); + + /** @When sending the request */ + $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); + } + + public function testSendWhenGenericClientExceptionRaisedThenThrowsHttpRequestFailed(): void + { + /** @Given a PSR-18 client that throws a generic ClientExceptionInterface */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with( + client: ThrowingClient::throwing(exception: new PsrClientException('generic failure')), + factory: $this->factory + )) + ->build(); + + /** @Then HttpRequestFailed is thrown */ + $this->expectException(HttpRequestFailed::class); + + /** @When sending the request */ + $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); + } + + public function testSendWhenProtocolRelativePathGivenThenThrowsMalformedPath(): void + { + /** @Given an Http instance with a base URL */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) + ->build(); + + /** @Then MalformedPath is thrown without invoking the transport */ + $this->expectException(MalformedPath::class); + + /** @When sending a request whose path is protocol-relative */ + $http->send(request: Request::create( + url: '//evil.example.com/attack', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); + } + + public function testSendWhenSchemePathGivenThenThrowsMalformedPath(): void + { + /** @Given an Http instance with a base URL */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) + ->build(); + + /** @Then MalformedPath is thrown */ + $this->expectException(MalformedPath::class); + + /** @When sending a request whose path contains a scheme */ + $http->send(request: Request::create( + url: 'javascript:alert(1)', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); + } + + public function testSendWhenControlCharsInPathGivenThenThrowsMalformedPath(): void + { + /** @Given an Http instance with a base URL */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) + ->build(); + + /** @Then MalformedPath is thrown */ + $this->expectException(MalformedPath::class); + + /** @When sending a request whose path contains control characters */ + $http->send(request: Request::create( + url: "/dragons\x00/evil", + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); + } + + public function testSendWhenNetworkExceptionRaisedThenPreservesPreviousChain(): void + { + /** @Given a network exception */ + $networkException = new PsrNetworkException('timeout'); + + /** @And an Http instance with a transport that throws it */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with( + client: ThrowingClient::throwing(exception: $networkException), + factory: $this->factory + )) + ->build(); + + /** @When sending the request */ + try { + $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); + self::fail('HttpNetworkFailed was expected.'); + } catch (HttpNetworkFailed $exception) { + /** @Then the previous exception is preserved in the chain */ + self::assertSame($networkException, $exception->getPrevious()); + } + } + + public function testSendWhenSchemePathGivenThenChainsPathContainsSchemeAsPrevious(): void + { + /** @Given an Http instance with a base URL */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) + ->build(); + + /** @And a request whose path contains a scheme */ + $request = Request::create( + url: 'https://attacker.com/steal', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @When sending the request */ + try { + $http->send(request: $request); + self::fail('MalformedPath was expected.'); + } catch (MalformedPath $exception) { + /** @Then the previous exception carries the offending path and a scheme-related reason */ + $previous = $exception->getPrevious(); + self::assertNotNull($previous); + self::assertStringContainsString('https://attacker.com/steal', $previous->getMessage()); + self::assertStringContainsString('scheme', $previous->getMessage()); + } + } + + public function testSendWhenSchemePathGivenThenMalformedPathExposesOffendingPath(): void + { + /** @Given an Http instance with a base URL */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) + ->build(); + + /** @And a request whose path contains a scheme */ + $request = Request::create( + url: 'https://attacker.com/steal', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + try { + /** @When sending the request */ + $http->send(request: $request); + } catch (MalformedPath $exception) { + /** @Then the exception exposes the offending path */ + self::assertSame('https://attacker.com/steal', $exception->path()); + } + } + + public function testSendWhenControlCharPathGivenThenChainsPathContainsControlCharsAsPrevious(): void + { + /** @Given an Http instance with a base URL */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) + ->build(); + + /** @And a request whose path contains a control character */ + $request = Request::create( + url: "/dragons\x00/evil", + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @When sending the request */ + try { + $http->send(request: $request); + self::fail('MalformedPath was expected.'); + } catch (MalformedPath $exception) { + /** @Then the previous exception carries the offending path and a control-character reason */ + $previous = $exception->getPrevious(); + self::assertNotNull($previous); + self::assertStringContainsString("/dragons\x00/evil", $previous->getMessage()); + self::assertStringContainsString('control characters', $previous->getMessage()); + } + } + + public function testSendWhenBaseUrlEmptyAndRelativePathGivenThenUsesPathDirectly(): void + { + /** @Given an Http instance with an empty base URL */ + $client = CapturingClient::returningStatus(statusCode: 200); + $http = Http::with(baseUrl: '', transport: NetworkTransport::with( + client: $client, + factory: $this->factory + )); + + /** @And a request with a relative path */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @When sending the request */ + $http->send(request: $request); + + /** @Then the PSR-7 request URI is the path as-is */ + self::assertNotNull($client->captured); + self::assertSame('/dragons', (string)$client->captured->getUri()); + } + + public function testSendWhenBaseUrlEndsWithSlashAndPathLeadsWithSlashThenSingleSlashJoinsThem(): void + { + /** @Given an Http instance with a trailing slash on the base URL */ + $client = CapturingClient::returningStatus(statusCode: 200); + $http = Http::with(baseUrl: 'https://api.example.com/', transport: NetworkTransport::with( + client: $client, + factory: $this->factory + )); + + /** @And a request whose path starts with a slash */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @When sending the request */ + $http->send(request: $request); + + /** @Then the composed URI joins them with exactly one slash */ + self::assertNotNull($client->captured); + self::assertSame('https://api.example.com/dragons', (string)$client->captured->getUri()); + } + + public function testSendWhenBaseUrlWithoutTrailingSlashAndPathWithoutLeadingSlashThenJoinsWithSingleSlash(): void + { + /** @Given an Http instance without trailing slash on the base URL */ + $client = CapturingClient::returningStatus(statusCode: 200); + $http = Http::with(baseUrl: 'https://api.example.com', transport: NetworkTransport::with( + client: $client, + factory: $this->factory + )); + + /** @And a request whose path lacks a leading slash */ + $request = Request::create( + url: 'dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @When sending the request */ + $http->send(request: $request); + + /** @Then the composed URI joins them with exactly one slash */ + self::assertNotNull($client->captured); + self::assertSame('https://api.example.com/dragons', (string)$client->captured->getUri()); + } + + public function testSendWhenQueryProvidedThenAppendsAsQueryString(): void + { + /** @Given an Http instance and a query payload */ + $client = CapturingClient::returningStatus(statusCode: 200); + $http = Http::with(baseUrl: 'https://api.example.com', transport: NetworkTransport::with( + client: $client, + factory: $this->factory + )); + + /** @And a request with query parameters */ + $request = Request::create( + url: '/dragons', + body: null, + query: ['sort' => 'name'], + method: Method::GET, + headers: Headers::from() + ); + + /** @When sending the request */ + $http->send(request: $request); + + /** @Then the composed URI includes the encoded query string */ + self::assertNotNull($client->captured); + self::assertSame('https://api.example.com/dragons?sort=name', (string)$client->captured->getUri()); + } + + public function testSendWhenCustomTransportRaisesNetworkFailureThenExceptionCarriesRequestContext(): void + { + /** @Given a custom transport that wraps a non-PSR network error and re-raises via the documented factory */ + $http = Http::with( + baseUrl: 'https://api.example.com', + transport: FailingTransport::raisingNetworkFailure( + reason: 'DNS resolution failed.', + cause: new RuntimeException('curl: getaddrinfo') + ) + ); + + try { + /** @When sending a request through the custom transport */ + $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::HEAD, + headers: Headers::from() + )); + self::fail('HttpNetworkFailed was expected.'); + } catch (HttpNetworkFailed $exception) { + /** @Then the exception carries the originating URL, method, and reason */ + self::assertSame('https://api.example.com/dragons', $exception->url()); + self::assertSame(Method::HEAD, $exception->method()); + self::assertSame('DNS resolution failed.', $exception->reason()); + } + } + + public function testSendWhenCustomTransportRaisesRequestInvalidThenExceptionCarriesRequestContext(): void + { + /** @Given a custom transport that maps an upstream validation error to HttpRequestInvalid */ + $http = Http::with( + baseUrl: 'https://api.example.com', + transport: FailingTransport::raisingRequestInvalid( + reason: 'Upstream validator rejected the payload.', + cause: new RuntimeException('validator: required field missing') + ) + ); + + try { + /** @When sending a request through the custom transport */ + $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::PATCH, + headers: Headers::from() + )); + self::fail('HttpRequestInvalid was expected.'); + } catch (HttpRequestInvalid $exception) { + /** @Then the exception carries the originating URL, method, and reason */ + self::assertSame('https://api.example.com/dragons', $exception->url()); + self::assertSame(Method::PATCH, $exception->method()); + self::assertSame('Upstream validator rejected the payload.', $exception->reason()); + } + } + + public function testSendWhenCustomTransportRaisesRequestFailureThenExceptionCarriesRequestContext(): void + { + /** @Given a custom transport that maps an upstream cURL error to HttpRequestFailed */ + $http = Http::with( + baseUrl: 'https://api.example.com', + transport: FailingTransport::raisingRequestFailure( + reason: 'cURL handle exhausted retries.', + cause: new RuntimeException('curl: too many retries') + ) + ); + + try { + /** @When sending a request through the custom transport */ + $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::PUT, + headers: Headers::from() + )); + self::fail('HttpRequestFailed was expected.'); + } catch (HttpRequestFailed $exception) { + /** @Then the exception carries the originating URL, method, and reason */ + self::assertSame('https://api.example.com/dragons', $exception->url()); + self::assertSame(Method::PUT, $exception->method()); + self::assertSame('cURL handle exhausted retries.', $exception->reason()); + } + } + + public function testSendWhenEmptyQueryArrayGivenThenNoTrailingQuestionMark(): void + { + /** @Given an Http instance and an empty query array */ + $client = CapturingClient::returningStatus(statusCode: 200); + $http = Http::with(baseUrl: 'https://api.example.com', transport: NetworkTransport::with( + client: $client, + factory: $this->factory + )); + + /** @And a request with an empty query array */ + $request = Request::create( + url: '/dragons', + body: null, + query: [], + method: Method::GET, + headers: Headers::from() + ); + + /** @When sending the request */ + $http->send(request: $request); + + /** @Then the composed URI has no trailing question mark */ + self::assertNotNull($client->captured); + self::assertSame('https://api.example.com/dragons', (string)$client->captured->getUri()); + } +} diff --git a/tests/Unit/PsrClientException.php b/tests/Unit/PsrClientException.php new file mode 100644 index 0000000..ce04926 --- /dev/null +++ b/tests/Unit/PsrClientException.php @@ -0,0 +1,12 @@ +createRequest('GET', 'https://api.example.com'); + } +} diff --git a/tests/Unit/PsrRequestException.php b/tests/Unit/PsrRequestException.php new file mode 100644 index 0000000..9348231 --- /dev/null +++ b/tests/Unit/PsrRequestException.php @@ -0,0 +1,18 @@ +createRequest('GET', 'https://api.example.com'); + } +} diff --git a/tests/SameSiteTest.php b/tests/Unit/SameSiteTest.php similarity index 67% rename from tests/SameSiteTest.php rename to tests/Unit/SameSiteTest.php index 7de269a..fad2078 100644 --- a/tests/SameSiteTest.php +++ b/tests/Unit/SameSiteTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http; +namespace Test\TinyBlocks\Http\Unit; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -11,13 +11,13 @@ final class SameSiteTest extends TestCase { #[DataProvider('sameSiteValueProvider')] - public function testBackedValueMatchesHeaderSpelling(SameSite $sameSite, string $expected): void + public function testValueWhenEnumCaseGivenThenMatchesHeaderSpelling(SameSite $sameSite, string $expected): void { /** @Given a SameSite enum case */ /** @When the backed value is read */ $actual = $sameSite->value; - /** @Then the value should match the casing expected by the Set-Cookie header */ + /** @Then the value matches the casing expected by the Set-Cookie header */ self::assertSame($expected, $actual); } @@ -26,7 +26,7 @@ public static function sameSiteValueProvider(): array return [ 'Lax strategy' => [SameSite::LAX, 'Lax'], 'None strategy' => [SameSite::NONE, 'None'], - 'Strict strategy' => [SameSite::STRICT, 'Strict'], + 'Strict strategy' => [SameSite::STRICT, 'Strict'] ]; } } diff --git a/tests/Unit/Server/HeadersTest.php b/tests/Unit/Server/HeadersTest.php new file mode 100644 index 0000000..4784010 --- /dev/null +++ b/tests/Unit/Server/HeadersTest.php @@ -0,0 +1,311 @@ + ['application/json; charset=utf-8']], $response->getHeaders()); + } + + public function testWithHeaderWhenChainedWithDistinctKeysThenBothPresentAlongsideDefault(): void + { + /** @Given an HTTP response */ + $response = Response::noContent(); + + /** @When two distinct custom headers are added in a chain */ + $actual = $response + ->withHeader('X-ID', '100') + ->withHeader('X-NAME', 'Xpto'); + + /** @Then both custom headers are present alongside the default Content-Type */ + self::assertSame( + ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => ['100'], 'X-NAME' => ['Xpto']], + $actual->getHeaders() + ); + } + + public function testWithHeaderWhenSameHeaderSetTwiceThenLastValueWins(): void + { + /** @Given an HTTP response with a default Content-Type */ + $response = Response::noContent(); + + /** @When we add the 'Content-Type' header twice with different values */ + $actual = $response + ->withHeader('Content-Type', 'application/json; charset=utf-8') + ->withHeader('Content-Type', 'application/json; charset=ISO-8859-1'); + + /** @Then the response carries the latest 'Content-Type' value */ + self::assertSame('application/json; charset=ISO-8859-1', $actual->getHeaderLine('Content-Type')); + + /** @And only one Content-Type entry exists */ + self::assertSame(['Content-Type' => ['application/json; charset=ISO-8859-1']], $actual->getHeaders()); + } + + public function testGetHeaderWhenHeaderMissingThenReturnsEmptyArray(): void + { + /** @Given an HTTP response with no custom headers */ + $response = Response::noContent(); + + /** @When we retrieve a missing header */ + $actual = $response->getHeader('Non-Existent-Header'); + + /** @Then the header is returned as an empty array */ + self::assertSame([], $actual); + } + + public function testWithAddedHeaderWhenDistinctValueGivenThenAppendsToExistingHeader(): void + { + /** @Given an HTTP response with a custom header */ + $response = Response::noContent()->withHeader('X-Trace', 'first'); + + /** @When a distinct value is added to the same header */ + $actual = $response->withAddedHeader('X-Trace', 'second'); + + /** @Then both values are preserved in the original order */ + self::assertSame('first, second', $actual->getHeaderLine('X-Trace')); + self::assertSame(['first', 'second'], $actual->getHeader('X-Trace')); + } + + public function testWithAddedHeaderWhenHeaderAbsentThenCreatesItWithGivenValue(): void + { + /** @Given an HTTP response without the target header */ + $response = Response::noContent(); + + /** @When a value is added for the absent header */ + $actual = $response->withAddedHeader('X-Trace', 'only-value'); + + /** @Then the header is created carrying the given value */ + self::assertSame(['only-value'], $actual->getHeader('X-Trace')); + self::assertSame( + ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['only-value']], + $actual->getHeaders() + ); + } + + public function testWithAddedHeaderWhenCaseMismatchedThenMatchesExistingHeader(): void + { + /** @Given an HTTP response with a custom header */ + $response = Response::noContent()->withHeader('X-Trace', 'first'); + + /** @When a value is added using a differently cased name */ + $actual = $response->withAddedHeader('x-trace', 'second'); + + /** @Then the value is appended preserving the original case of the header name */ + self::assertSame(['first', 'second'], $actual->getHeader('X-Trace')); + self::assertSame( + ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['first', 'second']], + $actual->getHeaders() + ); + } + + public function testWithoutHeaderWhenCaseMismatchedThenStillRemovesHeader(): void + { + /** @Given an HTTP response with a custom header */ + $response = Response::noContent()->withHeader('X-Trace', 'value'); + + /** @When the header is removed using a differently cased name */ + $actual = $response->withoutHeader('x-trace'); + + /** @Then the header is no longer present */ + self::assertFalse($actual->hasHeader('X-Trace')); + self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); + } + + public function testWithoutHeaderWhenAbsentThenIsNoOp(): void + { + /** @Given an HTTP response without the target header */ + $response = Response::noContent(); + + /** @When the missing header is requested to be removed */ + $actual = $response->withoutHeader('X-Trace'); + + /** @Then the headers remain unchanged */ + self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); + } + + public function testWithHeaderWhenHeaderAbsentThenCreatesIt(): void + { + /** @Given an HTTP response without the target header */ + $response = Response::noContent(); + + /** @When the header is replaced (i.e., set) */ + $actual = $response->withHeader('X-Trace', 'value'); + + /** @Then the header is created with the given value */ + self::assertSame(['value'], $actual->getHeader('X-Trace')); + } + + public function testWithHeaderWhenCaseMismatchedThenReplacesExistingHeader(): void + { + /** @Given an HTTP response with a custom header */ + $response = Response::noContent()->withHeader('X-Trace', 'first'); + + /** @When the header is replaced using a differently cased name */ + $actual = $response->withHeader('x-trace', 'second'); + + /** @Then the original casing is preserved and the value replaced */ + self::assertSame(['second'], $actual->getHeader('X-Trace')); + self::assertSame( + ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['second']], + $actual->getHeaders() + ); + } + + public function testNoContentWhenMultipleHeaderablesGivenThenCacheControlIsPresent(): void + { + /** @Given a Cache-Control header */ + $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noStore()); + + /** @And a Content-Type header */ + $contentType = ContentType::textPlain(); + + /** @When a response is created with both */ + $actual = Response::noContent($cacheControl, $contentType); + + /** @Then the Cache-Control header is present */ + self::assertSame(['no-store'], $actual->getHeader('Cache-Control')); + } + + public function testNoContentWhenMultipleHeaderablesGivenThenContentTypeReplacesDefault(): void + { + /** @Given a Cache-Control header */ + $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noStore()); + + /** @And a Content-Type header */ + $contentType = ContentType::textPlain(); + + /** @When a response is created with both */ + $actual = Response::noContent($cacheControl, $contentType); + + /** @Then the Content-Type header replaces the default */ + self::assertSame(['text/plain'], $actual->getHeader('Content-Type')); + } + + public function testNoContentWhenCacheControlWithEveryDirectiveGivenThenHeaderRendersAll(): void + { + /** @Given a Cache-Control header with multiple directives */ + $cacheControl = CacheControl::fromResponseDirectives( + ResponseCacheDirectives::maxAge(maxAgeInWholeSeconds: 10000), + ResponseCacheDirectives::noCache(), + ResponseCacheDirectives::noStore(), + ResponseCacheDirectives::noTransform(), + ResponseCacheDirectives::staleIfError(), + ResponseCacheDirectives::mustRevalidate(), + ResponseCacheDirectives::proxyRevalidate() + ); + + /** @When we create an HTTP response with no content, using the Cache-Control header */ + $actual = Response::noContent($cacheControl); + + /** @And the response includes the Cache-Control header */ + self::assertTrue($actual->hasHeader('Cache-Control')); + + /** @And the Cache-Control header lists every directive */ + $expected = 'max-age=10000, no-cache, no-store, no-transform, stale-if-error, ' + . 'must-revalidate, proxy-revalidate'; + + self::assertSame($expected, $actual->getHeaderLine('Cache-Control')); + self::assertSame([$expected], $actual->getHeader('Cache-Control')); + self::assertSame($cacheControl->toArray(), $actual->getHeaders()); + } + + public function testNoContentWhenContentTypeIsPdfThenHeaderReflectsIt(): void + { + /** @Given the Content-Type header set to application/pdf */ + $contentType = ContentType::applicationPdf(); + + /** @When the response is created with the Content-Type */ + $actual = Response::noContent($contentType); + + /** @Then the response carries Content-Type: application/pdf */ + self::assertTrue($actual->hasHeader('Content-Type')); + self::assertSame('application/pdf', $actual->getHeaderLine('Content-Type')); + } + + public function testNoContentWhenContentTypeIsHtmlThenHeaderReflectsIt(): void + { + /** @Given the Content-Type header set to text/html */ + $contentType = ContentType::textHtml(); + + /** @When the response is created with the Content-Type */ + $actual = Response::noContent($contentType); + + /** @Then the response carries Content-Type: text/html */ + self::assertSame('text/html', $actual->getHeaderLine('Content-Type')); + } + + public function testNoContentWhenContentTypeIsJsonThenHeaderReflectsIt(): void + { + /** @Given the Content-Type header set to application/json */ + $contentType = ContentType::applicationJson(); + + /** @When the response is created with the Content-Type */ + $actual = Response::noContent($contentType); + + /** @Then the response carries Content-Type: application/json */ + self::assertSame('application/json', $actual->getHeaderLine('Content-Type')); + } + + public function testNoContentWhenContentTypeIsPlainTextThenHeaderReflectsIt(): void + { + /** @Given the Content-Type header set to text/plain */ + $contentType = ContentType::textPlain(); + + /** @When the response is created with the Content-Type */ + $actual = Response::noContent($contentType); + + /** @Then the response carries Content-Type: text/plain */ + self::assertSame('text/plain', $actual->getHeaderLine('Content-Type')); + } + + public function testNoContentWhenContentTypeIsOctetStreamThenHeaderReflectsIt(): void + { + /** @Given the Content-Type header set to application/octet-stream */ + $contentType = ContentType::applicationOctetStream(); + + /** @When the response is created with the Content-Type */ + $actual = Response::noContent($contentType); + + /** @Then the response carries Content-Type: application/octet-stream */ + self::assertSame('application/octet-stream', $actual->getHeaderLine('Content-Type')); + } + + public function testNoContentWhenHeaderableEmitsStringValueThenWrapsItInList(): void + { + /** @Given a Headerable whose toArray() emits a string value (not a list) */ + $userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3'); + + /** @When a response is created with that header */ + $actual = Response::noContent($userAgent); + + /** @Then the header is preserved as a single-entry list */ + self::assertSame(['MyApp/1.2.3'], $actual->getHeader('User-Agent')); + } + + public function testNoContentWhenContentTypeIsFormUrlEncodedThenHeaderReflectsIt(): void + { + /** @Given the Content-Type header set to application/x-www-form-urlencoded */ + $contentType = ContentType::applicationFormUrlencoded(); + + /** @When the response is created with the Content-Type */ + $actual = Response::noContent($contentType); + + /** @Then the response carries Content-Type: application/x-www-form-urlencoded */ + self::assertSame('application/x-www-form-urlencoded', $actual->getHeaderLine('Content-Type')); + } +} diff --git a/tests/ProtocolVersionTest.php b/tests/Unit/Server/ProtocolVersionTest.php similarity index 60% rename from tests/ProtocolVersionTest.php rename to tests/Unit/Server/ProtocolVersionTest.php index 2737309..5635075 100644 --- a/tests/ProtocolVersionTest.php +++ b/tests/Unit/Server/ProtocolVersionTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http; +namespace Test\TinyBlocks\Http\Unit\Server; use PHPUnit\Framework\TestCase; -use TinyBlocks\Http\Response; +use TinyBlocks\Http\Server\Response; final class ProtocolVersionTest extends TestCase { - public function testProtocolVersion(): void + public function testWithProtocolVersionWhenInvokedThenReturnsResponseWithUpdatedProtocol(): void { /** @Given an HTTP response */ $response = Response::noContent(); @@ -18,9 +18,9 @@ public function testProtocolVersion(): void self::assertSame('1.1', $response->getProtocolVersion()); /** @When the protocol version is updated to HTTP/3 */ - $actual = $response->withProtocolVersion(version: '3'); + $actual = $response->withProtocolVersion('3'); - /** @Then the response should use the updated protocol version 3 */ + /** @Then the response uses the updated protocol version 3 */ self::assertSame('3', $actual->getProtocolVersion()); } -} \ No newline at end of file +} diff --git a/tests/Unit/Server/RequestTest.php b/tests/Unit/Server/RequestTest.php new file mode 100644 index 0000000..a806d60 --- /dev/null +++ b/tests/Unit/Server/RequestTest.php @@ -0,0 +1,469 @@ +factory = new Psr17Factory(); + } + + public function testDecodeWhenBodyGivenThenExposesTypedAccessors(): void + { + /** @Given a payload to send */ + $payload = [ + 'id' => PHP_INT_MAX, + 'name' => 'Drakengard Firestorm', + 'type' => 'Dragon', + 'weight' => 6000.00, + 'skills' => ['Fire Breath', 'Flight', 'Regeneration'], + 'is_legendary' => true + ]; + + /** @And a real PSR-7 server request with that JSON body */ + $serverRequest = new ServerRequest( + method: 'POST', + uri: 'https://api.example.com/dragons', + body: $this->factory->createStream(json_encode($payload, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)) + ); + + /** @When decoding the request body */ + $actual = Request::from(request: $serverRequest)->decode()->body(); + + /** @Then every typed accessor matches the original payload */ + self::assertSame($payload, $actual->toArray()); + self::assertSame($payload['id'], $actual->get(key: 'id')->toInteger()); + self::assertSame($payload['name'], $actual->get(key: 'name')->toString()); + self::assertSame($payload['type'], $actual->get(key: 'type')->toString()); + self::assertSame($payload['weight'], $actual->get(key: 'weight')->toFloat()); + self::assertSame($payload['skills'], $actual->get(key: 'skills')->toArray()); + self::assertSame($payload['is_legendary'], $actual->get(key: 'is_legendary')->toBoolean()); + } + + public function testDecodeWhenRouteHasSingleAttributeThenExposesIt(): void + { + /** @Given a route attribute carrying a single id */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com/dragons/dragon-id') + ->withAttribute('__route__', ['name' => '/v1/dragons/{id}', 'id' => 'dragon-id']); + + /** @When decoding the route attribute */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->get(key: 'id'); + + /** @Then the value is returned as a string */ + self::assertSame('dragon-id', $actual->toString()); + } + + public function testDecodeWhenRouteHasMultipleAttributesThenExposesEach(): void + { + /** @Given a set of route attributes */ + $attributes = ['id' => 'dragon-id', 'skill' => 'dragon-skill', 'weight' => 6000.00]; + + /** @And a server request carrying those attributes under the canonical route key */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withAttribute('__route__', ['name' => '/v1/dragons/{id}/skills/{skill}', ...$attributes]); + + /** @When decoding each attribute */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then each typed accessor matches */ + self::assertSame($attributes['id'], $route->get(key: 'id')->toString()); + self::assertSame($attributes['skill'], $route->get(key: 'skill')->toString()); + self::assertSame($attributes['weight'], $route->get(key: 'weight')->toFloat()); + } + + #[DataProvider('attributeConversionsProvider')] + public function testDecodeWhenAttributeTypedConversionRequestedThenReturnsExpectedValue( + string $key, + mixed $value, + string $method, + mixed $expected + ): void { + /** @Given a route attribute with the provided value */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withAttribute('__route__', ['name' => '/v1/dragons/{id}', $key => $value]); + + /** @When converting through the typed accessor */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->get(key: $key)->$method(); + + /** @Then the converted value matches the expected one */ + self::assertSame($expected, $actual); + } + + public function testDecodeWhenRouteAttributeIsScalarThenExposesIt(): void + { + /** @Given a scalar route attribute value */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withAttribute('__route__', 'dragon-id'); + + /** @When decoding the route attribute */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->get(key: 'id'); + + /** @Then the value is returned */ + self::assertSame('dragon-id', $actual->toString()); + } + + public function testDecodeWhenSlimStyleRouteObjectGivenThenResolvesArguments(): void + { + /** @Given a Slim-style route object that stores params in getArguments() */ + $routeObject = new class { + public function getArguments(): array + { + return ['id' => '42', 'email' => 'dragon@fire.com']; + } + }; + + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withAttribute('__route__', $routeObject); + + /** @When decoding the route */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then the params resolve from the object */ + self::assertSame('42', $route->get(key: 'id')->toString()); + self::assertSame(42, $route->get(key: 'id')->toInteger()); + self::assertSame('dragon@fire.com', $route->get(key: 'email')->toString()); + } + + public function testDecodeWhenMezzioStyleRouteResultGivenThenResolvesMatchedParams(): void + { + /** @Given a Mezzio-style route result object that uses getMatchedParams() */ + $routeResult = new class { + public function getMatchedParams(): array + { + return ['id' => '99', 'slug' => 'fire-dragon']; + } + }; + + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('routeResult', $routeResult); + + /** @When decoding using the known-attribute scan */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then the params resolve correctly */ + self::assertSame('99', $route->get(key: 'id')->toString()); + self::assertSame('fire-dragon', $route->get(key: 'slug')->toString()); + } + + public function testDecodeWhenSymfonyStyleRouteParamsGivenThenResolvesWithExplicitName(): void + { + /** @Given Symfony stores route params under _route_params */ + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('_route_params', ['id' => '7', 'category' => 'legendary']); + + /** @When decoding with the custom route attribute name */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(name: '_route_params'); + + /** @Then the params resolve correctly */ + self::assertSame('7', $route->get(key: 'id')->toString()); + self::assertSame('legendary', $route->get(key: 'category')->toString()); + } + + public function testDecodeWhenSymfonyAttributePresentThenFallbackScanFindsIt(): void + { + /** @Given Symfony stores params under _route_params and default __route__ is absent */ + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('_route_params', ['id' => '55']); + + /** @When decoding with the default route() */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then the fallback scan finds params under _route_params */ + self::assertSame('55', $route->get(key: 'id')->toString()); + } + + public function testDecodeWhenDirectAttributesPresentThenFallbackResolves(): void + { + /** @Given a request that stores route params as direct attributes */ + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('id', '123') + ->withAttribute('email', 'user@example.com'); + + /** @When decoding with the default route() */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then direct attributes resolve as fallback */ + self::assertSame('123', $route->get(key: 'id')->toString()); + self::assertSame('user@example.com', $route->get(key: 'email')->toString()); + } + + public function testDecodeWhenManualAttributesInjectedThenExposesValues(): void + { + /** @Given a request manually injecting route params via withAttribute() */ + $serverRequest = (new ServerRequest(method: 'POST', uri: 'https://api.example.com')) + ->withAttribute('__route__', ['id' => 'manually-injected', 'status' => 'active']); + + /** @When decoding */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then the injected values are returned */ + self::assertSame('manually-injected', $route->get(key: 'id')->toString()); + self::assertSame('active', $route->get(key: 'status')->toString()); + } + + public function testDecodeWhenRouteObjectExposesPublicPropertyThenResolvesIt(): void + { + /** @Given a route object exposing public properties */ + $routeObject = new class { + public array $arguments = ['id' => '10', 'name' => 'Hydra']; + }; + + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('__route__', $routeObject); + + /** @When decoding */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then public property values resolve */ + self::assertSame('10', $route->get(key: 'id')->toString()); + self::assertSame('Hydra', $route->get(key: 'name')->toString()); + } + + public function testDecodeWhenRouteObjectExposesNonArrayMethodAndPropertyThenFallsBackToEmpty(): void + { + /** @Given a route object whose matching method and property both return non-array values */ + $routeObject = new class { + public string $arguments = 'not-an-array'; + + public function getArguments(): string + { + return 'not-an-array'; + } + }; + + /** @And a server request carrying that object under the canonical route key */ + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('__route__', $routeObject); + + /** @When decoding any route attribute */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then safe defaults are returned because no array could be extracted */ + self::assertSame('', $route->get(key: 'id')->toString()); + } + + public function testDecodeWhenNoRouteAttributesGivenThenSafeDefaultsAreReturned(): void + { + /** @Given a server request with no route attributes at all */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com'); + + /** @When decoding any route attribute */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then safe defaults are returned */ + self::assertSame(0, $route->get(key: 'id')->toInteger()); + self::assertSame('', $route->get(key: 'name')->toString()); + self::assertSame(0.00, $route->get(key: 'weight')->toFloat()); + self::assertFalse($route->get(key: 'active')->toBoolean()); + self::assertSame([], $route->get(key: 'tags')->toArray()); + } + + public function testDecodeWhenParsedBodyPresentAndStreamEmptyThenUsesParsedBody(): void + { + /** @Given a payload already parsed by the framework */ + $payload = [ + 'id' => PHP_INT_MAX, + 'name' => 'Drakengard Firestorm', + 'type' => 'Dragon', + 'weight' => 6000.00, + 'skills' => ['Fire Breath', 'Flight', 'Regeneration'], + 'is_legendary' => true + ]; + + $serverRequest = (new ServerRequest(method: 'POST', uri: 'https://api.example.com')) + ->withBody($this->factory->createStream('')) + ->withParsedBody($payload); + + /** @When decoding the body */ + $actual = Request::from(request: $serverRequest)->decode()->body(); + + /** @Then the parsed body is exposed */ + self::assertSame($payload, $actual->toArray()); + self::assertSame($payload['id'], $actual->get(key: 'id')->toInteger()); + self::assertSame($payload['weight'], $actual->get(key: 'weight')->toFloat()); + self::assertSame($payload['is_legendary'], $actual->get(key: 'is_legendary')->toBoolean()); + } + + public function testDecodeWhenUriGivenThenExposesAsString(): void + { + /** @Given a full URI on the server request */ + $expectedUri = 'https://api.example.com/v1/dragons?sort=name&order=asc'; + $serverRequest = new ServerRequest(method: 'GET', uri: $expectedUri); + + /** @When decoding the URI */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->toString(); + + /** @Then the URI string matches */ + self::assertSame($expectedUri, $actual); + } + + public function testDecodeWhenQueryParamsPresentThenExposesTypedAccessors(): void + { + /** @Given query parameters present on the request URI */ + $queryParams = ['sort' => 'name', 'order' => 'asc', 'limit' => '50', 'active' => 'true']; + + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withQueryParams($queryParams); + + /** @When decoding the query parameters */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->queryParameters(); + + /** @Then every accessor matches */ + self::assertSame($queryParams, $actual->toArray()); + self::assertSame($queryParams['sort'], $actual->get(key: 'sort')->toString()); + self::assertSame($queryParams['order'], $actual->get(key: 'order')->toString()); + self::assertSame(50, $actual->get(key: 'limit')->toInteger()); + self::assertTrue($actual->get(key: 'active')->toBoolean()); + } + + public function testDecodeWhenQueryParamsAbsentThenSafeDefaultsReturned(): void + { + /** @Given a server request with no query parameters */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com'); + + /** @When decoding the query parameters */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->queryParameters(); + + /** @Then safe defaults are returned */ + self::assertSame([], $actual->toArray()); + self::assertSame('', $actual->get(key: 'sort')->toString()); + self::assertSame(0, $actual->get(key: 'page')->toInteger()); + self::assertSame(0.00, $actual->get(key: 'price')->toFloat()); + self::assertFalse($actual->get(key: 'active')->toBoolean()); + } + + public function testMethodWhenPostRequestGivenThenReturnsPostEnum(): void + { + /** @Given a POST server request */ + $serverRequest = new ServerRequest(method: 'POST', uri: 'https://api.example.com'); + + /** @When asking for the typed method */ + $actual = Request::from(request: $serverRequest)->method(); + + /** @Then the Method enum is returned */ + self::assertSame(Method::POST, $actual); + } + + #[DataProvider('httpMethodsProvider')] + public function testMethodWhenAnyHttpVerbGivenThenReturnsMatchingEnum( + string $methodString, + Method $expectedMethod + ): void { + /** @Given a server request with the specified HTTP verb */ + $serverRequest = new ServerRequest(method: $methodString, uri: 'https://api.example.com'); + + /** @When asking for the typed method */ + $actual = Request::from(request: $serverRequest)->method(); + + /** @Then the Method enum matches */ + self::assertSame($expectedMethod, $actual); + self::assertSame($methodString, $actual->value); + } + + public static function httpMethodsProvider(): array + { + return [ + 'GET method' => ['GET', Method::GET], + 'PUT method' => ['PUT', Method::PUT], + 'POST method' => ['POST', Method::POST], + 'HEAD method' => ['HEAD', Method::HEAD], + 'PATCH method' => ['PATCH', Method::PATCH], + 'TRACE method' => ['TRACE', Method::TRACE], + 'DELETE method' => ['DELETE', Method::DELETE], + 'OPTIONS method' => ['OPTIONS', Method::OPTIONS], + 'CONNECT method' => ['CONNECT', Method::CONNECT] + ]; + } + + public static function attributeConversionsProvider(): array + { + return [ + 'Float attribute conversion toString' => ['weight', 6000.00, 'toString', '6000'], + 'Float attribute conversion toInteger' => ['weight', 6000.00, 'toInteger', 6000], + 'Float attribute conversion toBoolean' => ['weight', 6000.00, 'toBoolean', true], + 'String attribute conversion toArray' => [ + 'skills', + '["Fire Breath", "Flight", "Regeneration"]', + 'toArray', + [] + ], + 'String attribute conversion toFloat' => ['weight', '6000.00', 'toFloat', 6000.00], + 'String attribute conversion toInteger' => ['id', '123', 'toInteger', 123], + 'String attribute conversion toBoolean' => [ + 'is_legendary', + 'true', + 'toBoolean', + true + ], + 'Integer attribute conversion toString' => ['id', 123, 'toString', '123'], + 'Integer attribute conversion toFloat' => ['id', 123, 'toFloat', 123.0], + 'Integer attribute conversion toBoolean' => ['id', 123, 'toBoolean', true], + 'Boolean attribute conversion toString' => ['is_legendary', true, 'toString', '1'], + 'Boolean attribute conversion toInteger' => ['is_legendary', true, 'toInteger', 1], + 'Boolean attribute conversion toFloat' => ['is_legendary', true, 'toFloat', 1.0], + 'Non-scalar attribute conversion toFloat defaults to 0.00' => ['meta', ['x' => 1], 'toFloat', 0.00], + 'Non-scalar attribute conversion toInteger defaults to 0' => ['meta', ['x' => 1], 'toInteger', 0], + 'Non-scalar attribute conversion toString defaults to empty' => ['meta', ['x' => 1], 'toString', ''], + 'Non-scalar attribute conversion toBoolean defaults to false' => ['meta', ['x' => 1], 'toBoolean', false] + ]; + } + + public function testDecodeWhenInvalidJsonBodyGivenThenReturnsEmptyArray(): void + { + /** @Given a non-JSON body */ + $serverRequest = (new ServerRequest(method: 'POST', uri: 'https://api.example.com')) + ->withBody($this->factory->createStream('{not valid json]')); + + /** @When decoding */ + $decoded = Request::from(request: $serverRequest)->decode(); + + /** @Then the body gracefully returns an empty array */ + self::assertSame([], $decoded->body()->toArray()); + } + + public function testDecodeWhenStreamAdvancedThenStillParsesFromStart(): void + { + /** @Given a seekable stream advanced past its start */ + $stream = $this->factory->createStream('{"name":"Hydra"}'); + $stream->getContents(); + + /** @And a server request using that stream */ + $serverRequest = new ServerRequest(method: 'POST', uri: 'https://api.example.com') + ->withBody($stream); + + /** @When decoding the request body */ + $decoded = Request::from(request: $serverRequest)->decode()->body(); + + /** @Then the body parses correctly despite the stream position */ + self::assertSame('Hydra', $decoded->get(key: 'name')->toString()); + + /** @And the stream is rewound so it can be re-read */ + self::assertSame('{"name":"Hydra"}', $stream->getContents()); + } + + public function testDecodeWhenEmptyStreamAndNonArrayParsedBodyThenReturnsEmpty(): void + { + /** @Given an empty stream and a non-array parsed body */ + $serverRequest = new ServerRequest(method: 'POST', uri: 'https://api.example.com') + ->withBody($this->factory->createStream('')) + ->withParsedBody(null); + + /** @When decoding */ + $decoded = Request::from(request: $serverRequest)->decode(); + + /** @Then the body gracefully returns an empty array */ + self::assertSame([], $decoded->body()->toArray()); + } +} diff --git a/tests/Unit/Server/ResponseTest.php b/tests/Unit/Server/ResponseTest.php new file mode 100644 index 0000000..32c31a1 --- /dev/null +++ b/tests/Unit/Server/ResponseTest.php @@ -0,0 +1,785 @@ +getProtocolVersion()); + + /** @And the body of the response matches the expected output */ + self::assertSame($expectedBody, $actual->getBody()->__toString()); + + /** @And the status code matches the provided code */ + self::assertSame($code->value, $actual->getStatusCode()); + self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); + + /** @And the reason phrase matches the code message */ + self::assertSame($code->message(), $actual->getReasonPhrase()); + + /** @And the default Content-Type is application/json; charset=utf-8 */ + self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); + } + + public function testOkWhenBodyGivenThenReturnsResponseWithStatus200(): void + { + /** @Given a body with data */ + $body = ['id' => PHP_INT_MAX, 'name' => 'Drakengard Firestorm', 'type' => 'Dragon', 'weight' => 6000.00]; + + /** @When the response is created with the body */ + $actual = Response::ok(body: $body); + + /** @Then the response carries the body encoded as JSON and a 200 status */ + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); + self::assertSame(Code::OK->value, $actual->getStatusCode()); + self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); + self::assertSame(Code::OK->message(), $actual->getReasonPhrase()); + self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); + } + + public function testCreatedWhenBodyGivenThenReturnsResponseWithStatus201(): void + { + /** @Given a body with data */ + $body = ['id' => 1, 'name' => 'New Resource', 'type' => 'Item', 'weight' => 100.00]; + + /** @When the response is created with the body */ + $actual = Response::created(body: $body); + + /** @Then the response carries the body and a 201 status */ + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); + self::assertSame(Code::CREATED->value, $actual->getStatusCode()); + self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); + self::assertSame(Code::CREATED->message(), $actual->getReasonPhrase()); + } + + public function testAcceptedWhenBodyGivenThenReturnsResponseWithStatus202(): void + { + /** @Given a body with data */ + $body = ['id' => 1, 'status' => 'Processing']; + + /** @When the response is created with the body */ + $actual = Response::accepted(body: $body); + + /** @Then the response carries the body and a 202 status */ + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); + self::assertSame(Code::ACCEPTED->value, $actual->getStatusCode()); + self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); + self::assertSame(Code::ACCEPTED->message(), $actual->getReasonPhrase()); + } + + public function testNoContentWhenInvokedThenReturnsEmptyBodyWithStatus204(): void + { + /** @When the response is created without body */ + $actual = Response::noContent(); + + /** @Then the body is empty and the status is 204 */ + self::assertEmpty($actual->getBody()->__toString()); + self::assertSame(Code::NO_CONTENT->value, $actual->getStatusCode()); + self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); + self::assertSame(Code::NO_CONTENT->message(), $actual->getReasonPhrase()); + } + + public function testBadRequestWhenBodyGivenThenReturnsResponseWithStatus400(): void + { + /** @Given a body with error details */ + $body = ['error' => 'Invalid request', 'message' => 'The request body is malformed.']; + + /** @When the response is created with the body */ + $actual = Response::badRequest(body: $body); + + /** @Then the status is 400 */ + self::assertSame(Code::BAD_REQUEST->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + } + + public function testUnauthorizedWhenBodyGivenThenReturnsResponseWithStatus401(): void + { + /** @Given a body with error details */ + $body = ['error' => 'Unauthorized', 'message' => 'Authentication is required.']; + + /** @When the response is created with the body */ + $actual = Response::unauthorized(body: $body); + + /** @Then the status is 401 */ + self::assertSame(Code::UNAUTHORIZED->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + } + + public function testForbiddenWhenBodyGivenThenReturnsResponseWithStatus403(): void + { + /** @Given a body with error details */ + $body = ['error' => 'Forbidden', 'message' => 'You do not have permission to access this resource.']; + + /** @When the response is created with the body */ + $actual = Response::forbidden(body: $body); + + /** @Then the status is 403 */ + self::assertSame(Code::FORBIDDEN->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + } + + public function testNotFoundWhenBodyGivenThenReturnsResponseWithStatus404(): void + { + /** @Given a body with error details */ + $body = ['error' => 'Not found', 'message' => 'The requested resource could not be found.']; + + /** @When the response is created with the body */ + $actual = Response::notFound(body: $body); + + /** @Then the status is 404 */ + self::assertSame(Code::NOT_FOUND->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + } + + public function testConflictWhenBodyGivenThenReturnsResponseWithStatus409(): void + { + /** @Given a body with conflict details */ + $body = ['error' => 'Conflict', 'message' => 'There is a conflict with the current state of the resource.']; + + /** @When the response is created with the body */ + $actual = Response::conflict(body: $body); + + /** @Then the status is 409 */ + self::assertSame(Code::CONFLICT->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + } + + public function testUnprocessableEntityWhenBodyGivenThenReturnsResponseWithStatus422(): void + { + /** @Given a body with validation errors */ + $body = ['error' => 'Validation Failed', 'message' => 'The input data did not pass validation.']; + + /** @When the response is created with the body */ + $actual = Response::unprocessableEntity(body: $body); + + /** @Then the status is 422 */ + self::assertSame(Code::UNPROCESSABLE_ENTITY->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + } + + public function testInternalServerErrorWhenBodyGivenThenReturnsResponseWithStatus500(): void + { + /** @Given a body with error details */ + $body = ['code' => 10000, 'message' => 'An unexpected error occurred on the server.']; + + /** @When the response is created with the body */ + $actual = Response::internalServerError(body: $body); + + /** @Then the status is 500 */ + self::assertSame(Code::INTERNAL_SERVER_ERROR->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + } + + public function testBadGatewayWhenBodyGivenThenReturnsResponseWithStatus502(): void + { + /** @Given a body with upstream failure details */ + $body = ['error' => 'Bad Gateway', 'message' => 'The upstream server returned an invalid response.']; + + /** @When the response is created with the body */ + $actual = Response::badGateway(body: $body); + + /** @Then the status is 502 */ + self::assertSame(Code::BAD_GATEWAY->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + } + + public function testServiceUnavailableWhenBodyGivenThenReturnsResponseWithStatus503(): void + { + /** @Given a body with service downtime details */ + $body = ['error' => 'Service Unavailable', 'message' => 'The service is temporarily unavailable.']; + + /** @When the response is created with the body */ + $actual = Response::serviceUnavailable(body: $body); + + /** @Then the status is 503 */ + self::assertSame(Code::SERVICE_UNAVAILABLE->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + } + + public static function responseFromProvider(): array + { + return [ + 'I am a teapot' => [ + 'code' => Code::IM_A_TEAPOT, + 'body' => 'Short and stout', + 'expectedBody' => 'Short and stout' + ], + 'OK with array body' => [ + 'code' => Code::OK, + 'body' => ['status' => 'success'], + 'expectedBody' => '{"status":"success"}' + ], + 'Accepted with null body' => [ + 'code' => Code::ACCEPTED, + 'body' => null, + 'expectedBody' => '' + ], + 'Not Found with string body' => [ + 'code' => Code::NOT_FOUND, + 'body' => 'Resource not found', + 'expectedBody' => 'Resource not found' + ], + 'Internal Server Error with complex body' => [ + 'code' => Code::INTERNAL_SERVER_ERROR, + 'body' => ['error' => ['code' => 500, 'message' => 'Crash']], + 'expectedBody' => '{"error":{"code":500,"message":"Crash"}}' + ] + ]; + } + + #[DataProvider('bodyProviderData')] + public function testOkWhenAnyBodyShapeGivenThenSerializesToExpectedString(mixed $body, string $expected): void + { + /** @Given the body contains the provided data */ + /** @When we create an HTTP response with the given body */ + $actual = Response::ok(body: $body); + + /** @Then the body matches the expected output */ + self::assertSame($expected, $actual->getBody()->__toString()); + } + + public function testWithBodyWhenInvokedThenReplacesBodyContent(): void + { + /** @Given an HTTP response without body */ + $response = Response::ok(body: null); + + /** @And a fresh PSR-7 stream carrying the replacement bytes */ + $replacement = new Psr17Factory()->createStream('This is a new body'); + + /** @When the body is replaced */ + $actual = $response->withBody($replacement); + + /** @Then the response body matches the new content */ + self::assertSame('This is a new body', $actual->getBody()->__toString()); + } + + public function testWithStatusWhenInvokedThenReturnsResponseWithUpdatedCode(): void + { + /** @Given an HTTP response */ + $response = Response::noContent(); + + /** @When calling withStatus with a new code */ + $updated = $response->withStatus(Code::OK->value); + + /** @Then the returned response reflects the new status code */ + self::assertSame(Code::OK->value, $updated->getStatusCode()); + } + + public static function bodyProviderData(): array + { + return [ + 'UnitEnum' => [ + 'body' => Color::RED, + 'expected' => 'RED' + ], + 'BackedEnum' => [ + 'body' => Status::PAID, + 'expected' => '1' + ], + 'Null value' => [ + 'body' => null, + 'expected' => '' + ], + 'Empty string' => [ + 'body' => '', + 'expected' => '' + ], + 'Simple object' => [ + 'body' => new Dragon(name: 'Drakengard Firestorm', weight: 6000.0), + 'expected' => '{"name":"Drakengard Firestorm","weight":6000.0}' + ], + 'Non-empty string' => [ + 'body' => 'Hello, World!', + 'expected' => 'Hello, World!' + ], + 'Serializer object' => [ + 'body' => new Order( + id: 1, + products: new Products(elements: [ + new Product(name: 'Product One', amount: new Amount(value: 100.50, currency: Currency::USD)), + new Product(name: 'Product Two', amount: new Amount(value: 200.75, currency: Currency::BRL)) + ]) + ), + 'expected' => json_encode([ + 'id' => 1, + 'products' => [ + ['name' => 'Product One', 'amount' => ['value' => 100.50, 'currency' => 'USD']], + ['name' => 'Product Two', 'amount' => ['value' => 200.75, 'currency' => 'BRL']] + ] + ], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION) + ], + 'Boolean true value' => [ + 'body' => true, + 'expected' => 'true' + ], + 'Boolean false value' => [ + 'body' => false, + 'expected' => 'false' + ], + 'Large integer value' => [ + 'body' => PHP_INT_MAX, + 'expected' => (string)PHP_INT_MAX + ], + 'DateTimeInterface value' => [ + 'body' => new DateTime('2024-12-16'), + 'expected' => '[]' + ] + ]; + } + + public function testGetBodyWhenInvokedThenStreamIsReadable(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When inspecting the stream */ + $isReadable = $stream->isReadable(); + + /** @Then the stream is readable */ + self::assertTrue($isReadable); + } + + public function testGetBodyWhenInvokedThenStreamIsWritable(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When inspecting the stream */ + $isWritable = $stream->isWritable(); + + /** @Then the stream is writable */ + self::assertTrue($isWritable); + } + + public function testGetBodyWhenInvokedThenStreamIsSeekable(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When inspecting the stream */ + $isSeekable = $stream->isSeekable(); + + /** @Then the stream is seekable */ + self::assertTrue($isSeekable); + } + + public function testGetBodyWhenContentsReadThenReturnsTheWrittenJsonWithoutRequiringRewind(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When reading the stream contents directly */ + $contents = $stream->getContents(); + + /** @Then the contents match the encoded body without needing a manual rewind */ + self::assertSame('{"name":"Hydra"}', $contents); + } + + public function testGetBodyWhenInvokedThenStreamStartsAtPositionZero(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When inspecting position before reading */ + $tell = $stream->tell(); + + /** @Then the position starts at zero */ + self::assertSame(0, $tell); + } + + public function testGetBodyWhenInvokedThenStreamIsNotAtEofBeforeReading(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When inspecting EOF before reading */ + $eof = $stream->eof(); + + /** @Then EOF is not yet reached */ + self::assertFalse($eof); + } + + public function testGetBodyWhenContentsReadThenStreamReachesEof(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When reading all contents to advance the cursor */ + $stream->getContents(); + + /** @Then EOF is signaled */ + self::assertTrue($stream->eof()); + } + + public function testGetBodyWhenSizeRequestedThenMatchesPayloadLength(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When asking the stream for its size */ + $size = $stream->getSize(); + + /** @Then the size matches the encoded payload length */ + self::assertSame(strlen('{"name":"Hydra"}'), $size); + } + + public function testGetBodyWhenClosedThenReportsNullSize(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When the stream is closed */ + $stream->close(); + + /** @Then the stream reports null size */ + self::assertNull($stream->getSize()); + } + + public function testGetBodyWhenClosedThenIsNotReadable(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When the stream is closed */ + $stream->close(); + + /** @Then the stream is no longer readable */ + self::assertFalse($stream->isReadable()); + } + + public function testGetBodyWhenClosedThenIsNotWritable(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When the stream is closed */ + $stream->close(); + + /** @Then the stream is no longer writable */ + self::assertFalse($stream->isWritable()); + } + + public function testGetBodyWhenClosedThenIsNotSeekable(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When the stream is closed */ + $stream->close(); + + /** @Then the stream is no longer seekable */ + self::assertFalse($stream->isSeekable()); + } + + public function testGetBodyWhenClosedThenEofReturnsFalse(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When the stream is closed */ + $stream->close(); + + /** @Then the detached stream reports it has not reached EOF */ + self::assertFalse($stream->eof()); + } + + public function testGetBodyWhenReadInChunksThenReturnsContentSegments(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When reading a small chunk from the beginning */ + $chunk = $stream->read(4); + + /** @Then the chunk matches the leading bytes of the encoded payload */ + self::assertSame('{"na', $chunk); + } + + public function testGetBodyWhenSeekedToOffsetThenTellMatchesOffset(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When seeking past the opening brace */ + $stream->seek(1); + + /** @Then the position reports the seeked offset */ + self::assertSame(1, $stream->tell()); + } + + public function testGetBodyWhenSeekedToOffsetThenSubsequentReadsResumeFromThatOffset(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When seeking past the opening brace */ + $stream->seek(1); + + /** @Then the next read starts at the seeked offset */ + self::assertSame('"', $stream->read(1)); + } + + public function testGetBodyWhenStreamWrittenAdditionalDataThenReturnsByteCount(): void + { + /** @Given a response stream positioned at end-of-file */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + $stream->seek(0, SEEK_END); + + /** @When appending one byte via the StreamInterface write() */ + $written = $stream->write('+'); + + /** @Then the write returns the byte count */ + self::assertSame(1, $written); + } + + public function testGetBodyWhenStreamWrittenAdditionalDataThenContentsGrowAccordingly(): void + { + /** @Given a response stream positioned at end-of-file */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + $stream->seek(0, SEEK_END); + + /** @When appending one byte via the StreamInterface write() */ + $stream->write('+'); + + /** @Then the stream size grows accordingly */ + self::assertSame(strlen('{"name":"Hydra"}+'), $stream->getSize()); + } + + public function testGetBodyWhenMetadataRequestedWithoutKeyThenReturnsArray(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When asking for the full metadata map */ + $metadata = $stream->getMetadata(); + + /** @Then the metadata is exposed as an array */ + self::assertIsArray($metadata); + } + + public function testGetBodyWhenMetadataRequestedForModeKeyThenExposesUnderlyingResourceMode(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When asking for the stream mode key */ + $mode = $stream->getMetadata('mode'); + + /** @Then the value reflects the in-memory resource mode */ + self::assertSame('w+b', $mode); + } + + public function testGetBodyWhenMetadataRequestedAfterCloseThenReturnsEmptyArray(): void + { + /** @Given a closed response stream */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + $stream->close(); + + /** @When asking for the full metadata map */ + $metadata = $stream->getMetadata(); + + /** @Then an empty array is returned */ + self::assertSame([], $metadata); + } + + public function testGetBodyWhenMetadataKeyRequestedAfterCloseThenReturnsNull(): void + { + /** @Given a closed response stream */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + $stream->close(); + + /** @When asking for a specific metadata key */ + $value = $stream->getMetadata('mode'); + + /** @Then null is returned */ + self::assertNull($value); + } + + public function testGetBodyWhenDetachedThenReturnsUnderlyingResource(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When detaching the underlying resource */ + $resource = $stream->detach(); + + /** @Then the returned value is a resource */ + self::assertIsResource($resource); + } + + public function testGetBodyWhenDetachedThenSizeIsNull(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When detaching the underlying resource */ + $stream->detach(); + + /** @Then the size collapses to null */ + self::assertNull($stream->getSize()); + } + + public function testGetBodyWhenDetachedThenIsNoLongerReadable(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When detaching the underlying resource */ + $stream->detach(); + + /** @Then the stream is no longer readable */ + self::assertFalse($stream->isReadable()); + } + + public function testGetBodyWhenClosedTwiceThenSecondCloseIsANoOp(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @And the stream is already closed */ + $stream->close(); + + /** @When closing the stream a second time */ + $stream->close(); + + /** @Then the stream remains detached and reports null size */ + self::assertNull($stream->getSize()); + } + + public function testGetBodyWhenClosedThenTellRaisesMissingResourceError(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @And the stream is closed */ + $stream->close(); + + /** @Then telling the position raises a missing-resource error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No resource available.'); + + /** @When asking for the position */ + $stream->tell(); + } + + public function testGetBodyWhenClosedThenSeekRaisesNonSeekableError(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @And the stream is closed */ + $stream->close(); + + /** @Then seeking raises a non-seekable error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not seekable.'); + + /** @When seeking on the closed stream */ + $stream->seek(0); + } + + public function testGetBodyWhenClosedThenReadRaisesNonReadableError(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @And the stream is closed */ + $stream->close(); + + /** @Then reading raises a non-readable error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not readable.'); + + /** @When reading from the closed stream */ + $stream->read(1); + } + + public function testGetBodyWhenReadLengthIsZeroThenRaisesNonReadableError(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @Then reading raises a non-readable error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not readable.'); + + /** @When reading with a non-positive length */ + $stream->read(0); + } + + public function testGetBodyWhenClosedThenWriteRaisesNonWritableError(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @And the stream is closed */ + $stream->close(); + + /** @Then writing raises a non-writable error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not writable.'); + + /** @When writing to the closed stream */ + $stream->write('payload'); + } + + public function testResponseFacadeForbidsInstantiationThroughAPrivateConstructor(): void + { + /** @Given the reflection of the public Response façade */ + $reflection = new ReflectionClass(Response::class); + + /** @And the constructor reflected from that class */ + $constructor = $reflection->getMethod('__construct'); + + /** @When invoking the empty private constructor on a bare instance */ + $constructor->invoke($reflection->newInstanceWithoutConstructor()); + + /** @Then the constructor is private to prevent direct instantiation */ + self::assertTrue($constructor->isPrivate()); + } + + public function testGetBodyWhenClosedThenGetContentsRaisesNonReadableError(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @And the stream is closed */ + $stream->close(); + + /** @Then reading the contents raises a non-readable error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not readable.'); + + /** @When asking for the full contents */ + $stream->getContents(); + } +} diff --git a/tests/ResponseWithCookiesTest.php b/tests/Unit/Server/ResponseWithCookiesTest.php similarity index 80% rename from tests/ResponseWithCookiesTest.php rename to tests/Unit/Server/ResponseWithCookiesTest.php index f93aebb..9886dbf 100644 --- a/tests/ResponseWithCookiesTest.php +++ b/tests/Unit/Server/ResponseWithCookiesTest.php @@ -2,40 +2,40 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http; +namespace Test\TinyBlocks\Http\Unit\Server; use PHPUnit\Framework\TestCase; use TinyBlocks\Http\CacheControl; use TinyBlocks\Http\Charset; use TinyBlocks\Http\ContentType; use TinyBlocks\Http\Cookie; -use TinyBlocks\Http\Response; use TinyBlocks\Http\ResponseCacheDirectives; use TinyBlocks\Http\SameSite; +use TinyBlocks\Http\Server\Response; final class ResponseWithCookiesTest extends TestCase { - public function testResponseWithSingleCookie(): void + public function testOkWhenSingleCookieGivenThenSetCookieHeaderReflectsConfiguration(): void { /** @Given a fully configured cookie */ $cookie = Cookie::create(name: 'session', value: 'abc') - ->httpOnly() ->secure() - ->withSameSite(sameSite: SameSite::STRICT) + ->httpOnly() ->withPath(path: '/') - ->withMaxAge(seconds: 604800); + ->withMaxAge(seconds: 604800) + ->withSameSite(sameSite: SameSite::STRICT); /** @When the response is built with the cookie */ $response = Response::ok(['ok' => true], $cookie); - /** @Then the Set-Cookie header should reflect the cookie configuration */ + /** @Then the Set-Cookie header reflects the cookie configuration */ self::assertSame( ['session=abc; Max-Age=604800; Path=/; Secure; HttpOnly; SameSite=Strict'], $response->getHeader('Set-Cookie') ); } - public function testResponseWithMultipleCookiesPreservesEachOne(): void + public function testOkWhenMultipleCookiesGivenThenEachIsPreservedAsSeparateHeader(): void { /** @Given an access cookie */ $accessCookie = Cookie::create(name: 'access_token', value: 'aaa') @@ -54,7 +54,7 @@ public function testResponseWithMultipleCookiesPreservesEachOne(): void /** @When the response is built with both cookies */ $response = Response::ok(['ok' => true], $accessCookie, $refreshCookie); - /** @Then both Set-Cookie header values should be present */ + /** @Then both Set-Cookie header values are present */ $setCookieHeaders = $response->getHeader('Set-Cookie'); self::assertCount(2, $setCookieHeaders); self::assertSame('access_token=aaa; Path=/; Secure; HttpOnly', $setCookieHeaders[0]); @@ -64,7 +64,7 @@ public function testResponseWithMultipleCookiesPreservesEachOne(): void ); } - public function testResponseWithCookiesCoexistsWithOtherHeaders(): void + public function testOkWhenCookiesAndOtherHeadersGivenThenAllPreserved(): void { /** @Given a cookie */ $cookie = Cookie::create(name: 'session', value: 'abc')->httpOnly()->secure(); @@ -78,13 +78,13 @@ public function testResponseWithCookiesCoexistsWithOtherHeaders(): void /** @When the response is built with all of them */ $response = Response::ok(['ok' => true], $contentType, $cacheControl, $cookie); - /** @Then every header should be preserved */ + /** @Then every header is preserved */ self::assertSame(['application/json; charset=utf-8'], $response->getHeader('Content-Type')); self::assertSame(['no-cache'], $response->getHeader('Cache-Control')); self::assertSame(['session=abc; Secure; HttpOnly'], $response->getHeader('Set-Cookie')); } - public function testResponseWithExpireCookieInstructsBrowserToDiscard(): void + public function testNoContentWhenExpireCookieGivenThenInstructsBrowserToDiscard(): void { /** @Given an expiration cookie with the same path used on set */ $cookie = Cookie::expire(name: 'refresh_token') @@ -96,7 +96,7 @@ public function testResponseWithExpireCookieInstructsBrowserToDiscard(): void /** @When a no-content response is built with the cookie */ $response = Response::noContent($cookie); - /** @Then the Set-Cookie header should instruct the browser to discard the cookie */ + /** @Then the Set-Cookie header instructs the browser to discard the cookie */ self::assertSame( ['refresh_token=; Max-Age=0; Path=/v1/sessions; Secure; HttpOnly; SameSite=Strict'], $response->getHeader('Set-Cookie') diff --git a/tests/Unit/ThrowingClient.php b/tests/Unit/ThrowingClient.php new file mode 100644 index 0000000..bfcb6d8 --- /dev/null +++ b/tests/Unit/ThrowingClient.php @@ -0,0 +1,27 @@ +exception; + } +} diff --git a/tests/Unit/UserAgentTest.php b/tests/Unit/UserAgentTest.php new file mode 100644 index 0000000..2a46c20 --- /dev/null +++ b/tests/Unit/UserAgentTest.php @@ -0,0 +1,71 @@ +toArray(); + + /** @Then the header contains only the product token */ + self::assertSame(['User-Agent' => 'MyApp'], $header); + } + + public function testFromWhenEmptyVersionGivenThenEquivalentToProductOnly(): void + { + /** @Given a product token with an explicitly empty version */ + $userAgent = UserAgent::from(product: 'MyApp', version: ''); + + /** @When reading the header array */ + $header = $userAgent->toArray(); + + /** @Then the header carries only the product token */ + self::assertSame(['User-Agent' => 'MyApp'], $header); + } + + public function testFromWhenProductAndVersionGivenThenRendersProductSlashVersion(): void + { + /** @Given a product token and a version */ + $userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3'); + + /** @When reading the header array */ + $header = $userAgent->toArray(); + + /** @Then the header contains the product and version combined */ + self::assertSame(['User-Agent' => 'MyApp/1.2.3'], $header); + } + + public function testToArrayWhenInvokedRepeatedlyThenReturnsSameValue(): void + { + /** @Given a UserAgent value object */ + $userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3'); + + /** @When calling toArray multiple times */ + $first = $userAgent->toArray(); + $second = $userAgent->toArray(); + + /** @Then both calls return identical arrays */ + self::assertSame($first, $second); + } + + public function testFromWhenEmptyProductGivenThenThrowsUserAgentProductIsEmpty(): void + { + /** @Then an exception is thrown */ + $this->expectException(UserAgentProductIsEmpty::class); + $this->expectExceptionMessage('User-Agent product must not be empty.'); + + /** @When constructing with an empty product token */ + UserAgent::from(product: ''); + } +}