Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
- Make parallel database isolation URL-aware. A persistent connection configured only with `url` currently skips worker-database rewriting, so ParaTest workers share one database. Normalize the connection before deciding whether it can be managed, keep in-memory SQLite process-local, and either rewrite supported persistent URLs per worker or fail with a clear error when automatic isolation is impossible.
- Port current Laravel's complete `tests/Support/SupportTestingEventFakeTest.php`, preserving Hypervel-specific EventFake coverage and coroutine-safe test behavior.
- Complete Testing assertion coverage: port the remaining current Laravel `TestResponseTest` cases through the incremental upstream-update workflow, and add focused coverage for `TestView`'s public assertion and string surface where Laravel has no equivalent suite.
- Add the repository-required `: void` return type to the remaining untyped HTTP test methods: 176 in `tests/Http/HttpClientTest.php`, 30 in `tests/Http/HttpRequestTrustedStateTest.php`, and 4 in `tests/Http/HttpRequestTrustedStateCoroutineTest.php`. Verify each file after the mechanical conversion.

## HTTP Server

Expand Down
2 changes: 2 additions & 0 deletions src/api-client/src/ApiRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ public function contentType(string $contentType): static
*/
public function asForm(): static
{
$this->ensureStructuredMutationAllowed();
$this->ensureStructuredBody();

if (! $this->isForm()) {
Expand All @@ -113,6 +114,7 @@ public function asForm(): static
*/
public function asJson(): static
{
$this->ensureStructuredMutationAllowed();
$this->ensureStructuredBody();

if (! $this->isJson()) {
Expand Down
17 changes: 13 additions & 4 deletions src/api-client/src/ApiResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Hypervel\Support\Traits\ForwardsCalls;
use JsonException;
use JsonSerializable;
use LogicException;
use Stringable;

/**
Expand Down Expand Up @@ -51,11 +52,19 @@ public function __isset(string $key): bool
}

/**
* Unset an attribute on the resource.
* Reject property assignment on the resource.
*/
public function __set(string $key, mixed $value): void
{
throw new LogicException('Resource data cannot be assigned through properties.');
}

/**
* Reject unsetting a property on the resource.
*/
public function __unset(string $key): void
{
$this->response->offsetUnset($key);
throw new LogicException('Resource data cannot be unset through properties.');
}

/**
Expand Down Expand Up @@ -185,14 +194,14 @@ public function offsetGet(mixed $offset): mixed
*/
public function offsetSet(mixed $offset, mixed $value): void
{
$this->response->offsetSet($offset, $value);
throw new LogicException('Resource data cannot be assigned through array offsets.');
}

/**
* Unset the value at the given offset.
*/
public function offsetUnset(mixed $offset): void
{
$this->response->offsetUnset($offset);
throw new LogicException('Resource data cannot be unset through array offsets.');
}
}
71 changes: 47 additions & 24 deletions src/api-client/src/PendingRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@

use BadMethodCallException;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Promise\PromiseInterface;
use Hypervel\ApiClient\Concerns\HasContext;
use Hypervel\Container\Container;
use Hypervel\Contracts\Container\Transient;
use Hypervel\Contracts\Support\Arrayable;
use Hypervel\Http\Client\ConnectionException;
use Hypervel\Http\Client\PendingRequest as ClientPendingRequest;
Expand All @@ -19,6 +21,7 @@
use Hypervel\Support\Traits\ForwardsCalls;
use InvalidArgumentException;
use JsonSerializable;
use LogicException;
use Psr\Http\Message\RequestInterface;
use Throwable;

Expand Down Expand Up @@ -75,7 +78,7 @@
* @method static connection(string $connection, ?array $config = null)
* @mixin ClientPendingRequest
*/
class PendingRequest
class PendingRequest implements Transient
{
use Conditionable;
use ForwardsCalls;
Expand All @@ -100,8 +103,6 @@ class PendingRequest

protected Pipeline $pipeline;

protected bool $bridgeRegistered = false;

protected ?ApiRequest $activeRequest = null;

/**
Expand Down Expand Up @@ -167,6 +168,26 @@ public function withoutApiMiddleware(): static
return $this;
}

/**
* Prepend middleware to the underlying HTTP request pipeline.
*
* @param callable(callable): callable $middleware
*/
public function prependMiddleware(callable $middleware): static
{
$this->getRequest()->prependMiddleware(function (callable $handler) use ($middleware): callable {
$middlewareHandler = $middleware($handler);

return function (RequestInterface $request, array $options) use ($middlewareHandler): PromiseInterface {
$this->activeRequest = null;

return $middlewareHandler($request, $options);
};
});

return $this;
}

/**
* Set the resource class for the request.
*
Expand Down Expand Up @@ -320,10 +341,15 @@ protected function sendRequest(string $method, mixed ...$arguments): ApiResource
{
try {
/** @var HttpResponse $response */
$response = $this->prepareClient()->{$method}(...$arguments);
/** @var ApiRequest $request */
$response = $this->getRequest()->{$method}(...$arguments);
$request = $this->activeRequest;

if ($request === null) {
throw new LogicException(
'HTTP middleware ahead of the API bridge short-circuited the request before API middleware could run.'
);
}

$apiResponse = ApiResponse::createFrom($response)
->withContext($request->context());
$apiResponse = $this->runResponseMiddleware($apiResponse);
Expand Down Expand Up @@ -357,33 +383,30 @@ protected function runResponseMiddleware(ApiResponse $response): ApiResponse
}

/**
* Prepare the HTTP client for an API request.
* Get the underlying HTTP pending request.
*/
protected function prepareClient(): ClientPendingRequest
protected function getRequest(): ClientPendingRequest
{
$request = $this->getRequest();
if ($this->request !== null) {
return $this->request;
}

if (! $this->bridgeRegistered) {
$this->bridgeRegistered = true;
$request->beforeSending(function (HttpRequest $request): RequestInterface {
$apiRequest = ApiRequest::createFrom($request)
$request = Http::createPendingRequest();
$request->prependMiddleware(function (callable $handler) use ($request): callable {
return function (RequestInterface $psrRequest, array $options) use ($handler, $request): PromiseInterface {
$httpRequest = (new HttpRequest($psrRequest))
->withData($options['hypervel_data'] ?? [])
->setRequestAttributes($request->attributes());
$apiRequest = ApiRequest::createFrom($httpRequest)
->withContext($this->context());

$this->activeRequest = $this->runRequestMiddleware($apiRequest);

return $this->activeRequest->toPsrRequest();
});
}

return $request;
}
return $handler($this->activeRequest->toPsrRequest(), $options);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
};
});

/**
* Get the underlying HTTP pending request.
*/
protected function getRequest(): ClientPendingRequest
{
return $this->request ??= Http::createPendingRequest();
return $this->request = $request;
}

/**
Expand Down
6 changes: 5 additions & 1 deletion src/docs/api-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,10 @@ These methods are useful when an integration needs access to details that are no

API middleware may change a request before it is sent or change a response before a resource is created. This differs from [Guzzle middleware](/docs/{{version}}/http-client#guzzle-middleware), which works directly with PSR-7 requests and responses.

API request middleware runs before the HTTP client's ordinary Guzzle middleware, `beforeSending` callbacks, and `RequestSending` event. This ensures lower-level middleware and observers receive the request after the API pipeline has finished preparing it. Middleware explicitly added with `prependMiddleware` runs before the API bridge and must pass the request onward rather than returning a response early.

Because Guzzle middleware and `beforeSending` callbacks run later, they may overwrite changes made by API request middleware. Place body-dependent work, such as request signing, in `beforeSending` or Guzzle middleware so it uses the final request body.

<a name="request-middleware"></a>
### Request Middleware

Expand Down Expand Up @@ -600,7 +604,7 @@ $request
->withoutData('role');
```

Structured request data belongs to request bodies. Calling `withData`, `mergeData`, or `withoutData` on a `GET` or `HEAD` request will throw an exception. Use `withQuery` and `withoutQuery` to change the query string instead.
Structured request data belongs to request bodies. Calling `withData`, `mergeData`, `withoutData`, `asJson`, or `asForm` on a `GET` or `HEAD` request will throw an exception. Use `withQuery` and `withoutQuery` to change the query string instead. The `withBody` method remains available when an API explicitly requires a raw body on one of these methods.

The `asJson` and `asForm` methods may be used to convert structured request data between JSON and form encoding:

Expand Down
20 changes: 18 additions & 2 deletions src/docs/http-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,13 +249,14 @@ For convenience, you may use the `acceptJson` method to quickly specify that you
$response = Http::acceptJson()->get('http://example.com/users');
```

The `withHeaders` method merges new headers into the request's existing headers. If needed, you may replace all of the headers entirely using the `replaceHeaders` method:
The `withHeaders` method merges new headers into the request's existing headers. If needed, you may replace values for matching header keys while retaining unrelated headers using the `replaceHeaders` method:

```php
$response = Http::withHeaders([
'X-Original' => 'foo',
'X-Keep' => 'bar',
])->replaceHeaders([
'X-Replacement' => 'bar',
'X-Original' => 'replacement',
])->post('http://example.com/users', [
'name' => 'Taylor',
]);
Expand Down Expand Up @@ -537,6 +538,21 @@ $response = Http::withResponseMiddleware(
)->get('http://example.com');
```

The `withMiddleware` method appends a complete Guzzle middleware callable to the pending request. If an integration must run before all existing middleware, including global middleware, use `prependMiddleware`:

```php
use Hypervel\Support\Facades\Http;

$traceId = 'request-id';

$response = Http::prependMiddleware(
fn (callable $handler) => fn ($request, array $options) => $handler(
$request->withHeader('X-Trace-ID', $traceId),
$options,
)
)->get('http://example.com');
```

<a name="global-middleware"></a>
#### Global Middleware

Expand Down
6 changes: 6 additions & 0 deletions src/docs/porting-from-laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
- [Configuration](#configuration)
- [Other API Differences](#other-api-differences)
- [HTTP Client and Concurrency](#http-client-and-concurrency)
- [Scout](#scout)
- [Rate Limiting](#rate-limiting)
- [Pagination](#pagination)
- [Dates](#dates)
Expand Down Expand Up @@ -479,6 +480,11 @@ For concurrent HTTP requests, replace Laravel's `Http::pool` and `Http::batch` p

Hypervel's `Concurrency` facade provides `coroutine`, `process`, and `sync` drivers. Laravel's `fork` driver is not available because coroutines are Hypervel's native lightweight execution model. Use the default `coroutine` driver for normal concurrent application work and reserve `process` for work that requires operating system process isolation. See the [concurrency documentation](/docs/{{version}}/concurrency#choosing-a-driver).

<a name="scout"></a>
### Scout

Hypervel compiles integer and float values passed to Scout's Algolia `where`, `whereIn`, and `whereNotIn` methods as numeric comparisons. Numeric-looking strings remain facet values. When porting an Algolia index, ensure the indexed attribute type matches the PHP value type used by these filters.

<a name="rate-limiting"></a>
### Rate Limiting

Expand Down
2 changes: 1 addition & 1 deletion src/docs/saloon.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ Requests and pending requests provide fluent methods that mirror Hypervel's HTTP
<a name="headers"></a>
### Headers

Use `withHeader` to add one header or `withHeaders` to merge several headers. The `replaceHeaders` method replaces the complete header collection:
Use `withHeader` to add one header or `withHeaders` to merge several headers. The `replaceHeaders` method replaces matching header names without removing unrelated headers. Header names are matched without regard to casing:

```php
$request
Expand Down
18 changes: 14 additions & 4 deletions src/docs/scout.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class Post extends Model
<a name="queueing"></a>
### Indexing Mode

By default, Hypervel Scout indexes models asynchronously (without blocking the worker), meaning the worker can continue to handle other requests while indexing completes. Additionally, indexing is scheduled via `Coroutine::defer` and runs after the HTTP response has been sent to the user, meaning there's no delay in returning the response. This default mode works well for most applications and requires no queue worker or other infrastructure.
By default, Hypervel Scout defers indexing during HTTP requests until after the response has been sent to the user. Scout preserves the order of model updates and deletions while draining this work. Outside an active HTTP request, such as in a console command or queue job, Scout performs indexing immediately. This default mode works well for most applications and requires no queue worker or other infrastructure.

If you need indexing failures to be persistently tracked and retried, or to wait for a database transaction to commit, you can switch Scout to queue-based indexing by enabling the `queue.enabled` option in your `config/scout.php` configuration file:

Expand All @@ -94,7 +94,7 @@ Of course, if you customize the connection and queue that Scout jobs utilize, yo
php artisan queue:work redis --queue=scout
```

Each queue option may also be set via the `SCOUT_QUEUE`, `SCOUT_QUEUE_CONNECTION`, and `SCOUT_QUEUE_NAME` environment variables. If the nested `enabled` option is omitted, Scout keeps its default deferred, non-queued indexing mode.
Each queue option may also be set via the `SCOUT_QUEUE`, `SCOUT_QUEUE_CONNECTION`, and `SCOUT_QUEUE_NAME` environment variables. If the nested `enabled` option is omitted, Scout keeps its default non-queued mode, deferring work during HTTP requests and running it immediately elsewhere.

#### Transaction-Safe Dispatch

Expand Down Expand Up @@ -360,7 +360,7 @@ use Hypervel\Scout\Attributes\SearchUsingPrefix;
*
* @return array<string, mixed>
*/
#[SearchUsingPrefix(['id', 'email'])]
#[SearchUsingPrefix(['email'])]
#[SearchUsingFullText(['bio'])]
public function toSearchableArray(): array
{
Expand All @@ -373,6 +373,10 @@ public function toSearchableArray(): array
}
```

The database engine always treats your model's Eloquent primary key as its identity. Integer primary keys are searched using exact equality and should not be listed in `SearchUsingPrefix` or `SearchUsingFullText`. String, UUID, and ULID primary keys may still use partial matching.

When using PostgreSQL, only include text-compatible columns in the default, prefix, and full-text searchable sets. Scout automatically handles the model's integer primary key, but other numeric columns should be omitted unless they are searched through an explicit query constraint.

> [!WARNING]
> Before specifying that a column should use full text query constraints, ensure that the column has been assigned a [full text index](/docs/{{version}}/migrations#available-index-types).

Expand Down Expand Up @@ -718,6 +722,10 @@ Todo::search('Groceries')->options([
])->get();
```

Scout owns the `page` and `per_page` parameters used by Typesense. Choose the result size with Scout's `take` or `paginate` methods, or configure the `typesense.max_total_results` limit for large `take` queries. When this setting is omitted, `take` is limited to 1,000 results. Model search parameters and values passed to `options` may customize every other Typesense search parameter.

Typesense accepts between 1 and 250 results per paginator page. Scout rejects values outside that range before sending the search request.

<a name="indexing"></a>
## Third-Party Engine Indexing

Expand Down Expand Up @@ -1026,7 +1034,7 @@ public function boot(): void
}
```

Custom job classes should extend the corresponding default job and override only the methods you need to change. These overrides only affect queue-mode indexing — in the default mode, indexing runs inline via `Coroutine::defer` and does not pass through a job class.
Custom job classes should extend the corresponding default job and override only the methods you need to change. These overrides only affect queue-mode indexing. In the default mode, Scout defers indexing during HTTP requests and performs it immediately in other contexts without passing through a job class.

You may configure the attempts, retry delay, and maximum unhandled exceptions for the default jobs in `config/scout.php`:

Expand Down Expand Up @@ -1119,6 +1127,8 @@ $orders = Order::search('Star Trek')->whereNotIn(
)->get();
```

When using Algolia, Scout preserves the type of each filter value. Pass integers and finite floats for numeric filters, strings for text filters, and booleans for boolean filters. Numeric-looking strings remain text values.

> [!WARNING]
> If your application is using Meilisearch, you must configure your application's [filterable attributes](#meilisearch-index-settings) before utilizing Scout's "where" clauses.

Expand Down
4 changes: 4 additions & 0 deletions src/foundation/src/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,10 @@ function __(?string $key = null, array $replace = [], ?string $locale = null): a
*/
function uri(UriInterface|\Stringable|array|string $uri, mixed $parameters = [], bool $absolute = true): Uri
{
if (! is_array($uri)) {
$uri = (string) $uri;
}

return match (true) {
is_array($uri) || str_contains($uri, '\\') => Uri::action($uri, $parameters, $absolute),
str_contains($uri, '.') && Route::has($uri) => Uri::route($uri, $parameters, $absolute),
Expand Down
Loading