Skip to content
Merged
117 changes: 117 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,128 @@ The return type of the interface method selects how the HTTP response is handled
| `string` | Raw response body |
| PSR-7 `MessageInterface`| The HTTP message object |

### Mapping responses to a domain object

Instead of a raw array, a method can return typed, immutable domain objects.
Give `#[WebQuery]` a `factory` (and a `type`) — this is the same factory
mechanism `ray/media-query` provides for `#[DbQuery]`, applied to HTTP
responses.

```php
<?php
use Ray\MediaQuery\Annotation\WebQuery;

interface ProductApiInterface
{
#[WebQuery('product_item', type: 'row', factory: ProductFactory::class)]
public function get(string $id): Product;

#[WebQuery('product_list', factory: ProductFactory::class)]
/** @return array<Product> */
public function list(string $status): array;
}
```

The factory is resolved through the DI injector, so it can depend on domain
services and apply business logic while building the object:

```php
<?php
final class ProductFactory
{
public function __construct(
private TaxCalculator $tax,
) {
}

public function factory(string $name, int $price): Product
{
return new Product($name, $this->tax->applyTax($price));
}
}

final class Product
{
public function __construct(
public readonly string $name,
public readonly int $price,
) {
}
}
```

The decoded JSON is passed to the factory method as **named arguments**: each
JSON key is matched to a parameter by name, unknown keys are ignored, and a
missing required argument throws `InvalidWebFactoryKeyException`. (This is the
web counterpart of media-query's positional `PDO::FETCH_FUNC` binding.)

`type` selects single object vs. list:

| `type` | JSON response | Result |
|--------------|--------------------------|-----------------|
| `'row'` | object `{...}` | one object |
| `'row_list'` | array `[{...}, {...}]` | `array<Object>` |

`type` defaults to `'row_list'`. A `'row'` method whose response is a list
takes the first element; a `'row_list'` method whose response is a single
object wraps it into a one-element list.

You can also map straight to an entity **without** a factory: when the return
type (or the `@return array<Entity>` docblock) is a class, each response is
hydrated through the entity constructor.

```php
#[WebQuery('product_item', type: 'row')]
public function get(string $id): Product; // built via Product::__construct
```

### Composing results with PostFetch

To wrap or aggregate the fetched objects into another type (totals, metadata,
…), let the return type implement `PostFetchInterface`. Its static
`fromContext()` receives the fetch result and returns the final object. It runs
after the factory, carries no dependencies by design, and is the web analogue
of media-query's `PostQueryInterface` (named *PostFetch* because a web call is a
single fetch, with no multi-statement query context to span).

```php
<?php
use Ray\MediaQuery\PostFetchContext;
use Ray\MediaQuery\PostFetchInterface;

final class ProductList implements PostFetchInterface
{
/** @param array<Product> $items */
public function __construct(
public readonly array $items,
public readonly int $total,
) {
}

public static function fromContext(PostFetchContext $context): static
{
/** @var array<Product> $items */
$items = is_array($context->result) ? $context->result : [];

return new self($items, count($items));
}
}
```

```php
#[WebQuery('product_list', factory: ProductFactory::class)]
public function listAggregate(string $status): ProductList;
```

`PostFetchContext` exposes the fetch `result`, the original method arguments
(`query`), and the `#[WebQuery]` annotation (`webQuery`).

## Features

- **Web API Queries**: Execute HTTP requests via interface methods
- **URI Template Support**: Dynamic URL parameter binding with `{param}` syntax
- **Multiple Response Types**: JSON array, string, or PSR-7 message
- **Domain Object Mapping (BDR)**: Map responses to typed domain objects via an injectable factory, with optional `PostFetch` composition
- **Parameter Injection**: Automatic parameter conversion and injection
- **HTTP Client Integration**: Built on the Guzzle HTTP client

Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"require": {
"php": "^8.2",
"guzzlehttp/guzzle": "^7.2",
"phpdocumentor/reflection-docblock": "^5.3 || ^6.0",
"psr/http-message": "^2.0",
"ray/aop": "^2.18",
"ray/di": "^2.18",
Expand Down
2 changes: 2 additions & 0 deletions src/Annotation/WebQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ final class WebQuery
{
public function __construct(
public string $id,
public string $type = 'row_list',
public string $factory = '',
) {
}
}
20 changes: 20 additions & 0 deletions src/Exception/EntityWithoutConstructorException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Ray\MediaQuery\Exception;

/**
* Thrown when an entity class has no constructor.
*
* The Web layer hydrates entities through constructor named arguments, so a
* class without a constructor cannot receive the response data.
*/
final class EntityWithoutConstructorException extends LogicException
{
/** @param string $entity Entity class name. */
public function __construct(string $entity)
{
parent::__construct("Entity '{$entity}' has no constructor; constructor hydration requires one");
}
}
16 changes: 16 additions & 0 deletions src/Exception/InvalidWebEntityException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Ray\MediaQuery\Exception;

/**
* Thrown when the return-type entity class cannot be found.
*/
final class InvalidWebEntityException extends LogicException
{
public function __construct(string $entity)
{
parent::__construct("Entity class not found: {$entity}");
}
}
19 changes: 19 additions & 0 deletions src/Exception/InvalidWebFactoryException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Ray\MediaQuery\Exception;

/**
* Thrown when #[WebQuery(factory: ...)] points to a class or method that is
* neither a static method nor a class resolvable via DI.
*/
final class InvalidWebFactoryException extends LogicException
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
public function __construct(string $factory, string $method)
{
parent::__construct(
"Factory '{$factory}::{$method}()' is not callable; provide a static method or a class resolvable via DI",
);
}
}
27 changes: 27 additions & 0 deletions src/Exception/MissingResponseKeyException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Ray\MediaQuery\Exception;

use function implode;

/**
* Thrown when a required constructor or factory parameter is absent from the
* decoded HTTP response.
*/
final class MissingResponseKeyException extends LogicException
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
/**
* @param string $target Target being built, e.g. "App\Product::__construct".
* @param string $missingParam The parameter that was not found.
* @param list<string> $availableKeys Keys present in the response.
*/
public function __construct(string $target, string $missingParam, array $availableKeys)
{
$available = implode(', ', $availableKeys);
parent::__construct(
"'{$target}' requires parameter '{$missingParam}' but it was not found in response keys: [{$available}]",
);
}
}
9 changes: 9 additions & 0 deletions src/MediaQueryWebModule.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use Override;
use phpDocumentor\Reflection\DocBlockFactory;
use phpDocumentor\Reflection\DocBlockFactoryInterface;
use Ray\Di\AbstractModule;
use Ray\MediaQuery\Annotation\Qualifier\FactoryMethod;
use Ray\MediaQuery\Annotation\Qualifier\UriTemplateBindings;
use Ray\MediaQuery\Annotation\Qualifier\WebApiList;
use Ray\MediaQuery\Annotation\WebQuery;
Expand Down Expand Up @@ -38,5 +41,11 @@ public function configure(): void

$this->bind()->annotatedWith(WebApiList::class)->toInstance($config);
$this->bind()->annotatedWith(UriTemplateBindings::class)->toInstance($this->config->urlTemplateBindings);

// BDR factory layer
$this->bind(DocBlockFactoryInterface::class)->toInstance(DocBlockFactory::createInstance());
$this->bind(ReturnEntityInterface::class)->to(ReturnEntity::class);
$this->bind(WebResponseMapperInterface::class)->to(WebResponseMapper::class);
$this->bind()->annotatedWith(FactoryMethod::class)->toInstance('factory');
}
}
28 changes: 28 additions & 0 deletions src/PostFetchContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Ray\MediaQuery;

use Ray\MediaQuery\Annotation\WebQuery;

/**
* Value object passed to PostFetchInterface::fromContext().
*
* Carries the fetch result, the original method arguments, and the
* #[WebQuery] annotation so that post-processors have full context.
*/
final class PostFetchContext
{
/**
* @param mixed $result Output of fetchRow() or fetchAll().
* @param array<string, string> $query Method arguments passed to the interceptor.
* @param WebQuery $webQuery The annotation (id, type, factory).
*/
public function __construct(
public readonly mixed $result,
public readonly array $query,
public readonly WebQuery $webQuery,
) {
}
}
19 changes: 19 additions & 0 deletions src/PostFetchInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Ray\MediaQuery;

/**
* Implemented by return-type classes that wish to post-process fetch results.
*
* The static named constructor receives all context produced by the fetch layer
* and returns a fully formed domain object — no DI involved.
*
* Analogous to PostQueryInterface in ray/media-query, but for the Web layer
* where a single HTTP request replaces multiple SQL statements.
*/
interface PostFetchInterface
{
public static function fromContext(PostFetchContext $context): static;
}
Loading
Loading