Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Added reusable Fetch manager macros through Laravel's `Macroable` trait.
- Added request-local `baseUrl()` support for relative request and download URLs.
- Added `FetchResponse::statusIs()` and named helpers for common HTTP statuses.
- Added application-wide Laravel listeners for the existing request started, completed, and failed events.

### Changed

- Documented tested Android and iOS compatibility with NativePHP Mobile 4.2.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Laravel's HTTP client instead.
- Streaming file downloads
- Opt-in retries with exponential backoff
- Per-attempt timeouts and explicit cancellation
- Request-local base URLs and reusable application macros
- Fluent PHP and official JavaScript clients
- Request fakes for Pest and PHPUnit tests

Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default defineConfig({
items: [
{ text: "JavaScript", link: "/javascript" },
{ text: "Testing", link: "/testing" },
{ text: "Validation and errors", link: "/errors" },
{ text: "API reference", link: "/api-reference" },
{ text: "Compatibility", link: "/compatibility" },
],
Expand Down
41 changes: 38 additions & 3 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pending request mutate and return that same request.
| --- | --- |
| `request()` | Create a request with a pre-generated UUIDv7 ID. |
| `id()` | Read the stable request ID before execution. |
| `baseUrl(url)` | Resolve relative request and download URLs against a request-local base URL. |
| `withHeader(name, value)` | Add or case-insensitively replace one header. |
| `withHeaders(headers)` | Add or replace multiple headers. |
| `withToken(token, type = 'Bearer')` | Set authorization. |
Expand All @@ -17,7 +18,7 @@ pending request mutate and return that same request.
| `asForm()` | Select RFC 1738 form body mode. |
| `withBody(body, contentType = 'text/plain')` | Select a raw string body. |
| `timeout(seconds)` | Set the per-attempt native timeout. |
| `retry(...)` | Enable and configure native retries. |
| `retry(times = 3, delay = 500, multiplier = 2.0, maxDelay = 30000, statuses = [])` | Enable and configure native retries. `delay` and `maxDelay` are in milliseconds. |
| `attach(...)` | Append one multipart file. |
| `attachMany(attachments)` | Validate and append multiple files. |
| `get(url, query = [])` | Start a GET request. |
Expand All @@ -31,20 +32,45 @@ pending request mutate and return that same request.
The facade also provides `fake()`, `restore()`, `isFaking()`, `fakeInstance()`,
`assertSent()`, `assertNotSent()`, and `assertSentCount()`.

The Fetch manager uses Laravel's `Macroable` trait. Use `macro(name, callable)`
or `mixin(object)` to register extensions, `hasMacro(name)` to inspect them,
and `flushMacros()` to clear them. A request macro should return a newly
configured pending request so request state is not shared.

## FetchResponse

| Method | Description |
| --- | --- |
| `from(requestId, status, headers = [], body = '')` | Build from event arguments. |
| `fromEvent(event)` | Build from `FetchRequestCompleted`. |
| `make(status = 200, body = '', headers = [])` | Build a response for tests. |
| `withRequestId(requestId)` | Return a copy with a new request ID. |
| `requestId()` | Return the associated request ID. |
| `status()` | Return the HTTP status. |
| `headers()` | Return every response header. |
| `header(name, default = null)` | Case-insensitive header lookup. |
| `body()` | Return the raw response body. |
| `json(key = null, default = null)` | Decode JSON and optionally read a dot key. |
| `statusIs(status)` | Status exactly matches the given integer. |
| `ok()` | Status is exactly 200. |
| `created()` | Status is exactly 201. |
| `accepted()` | Status is exactly 202. |
| `noContent()` | Status is exactly 204. |
| `movedPermanently()` | Status is exactly 301. |
| `found()` | Status is exactly 302. |
| `badRequest()` | Status is exactly 400. |
| `unauthorized()` | Status is exactly 401. |
| `paymentRequired()` | Status is exactly 402. |
| `forbidden()` | Status is exactly 403. |
| `notFound()` | Status is exactly 404. |
| `methodNotAllowed()` | Status is exactly 405. |
| `requestTimeout()` | Status is exactly 408. |
| `conflict()` | Status is exactly 409. |
| `gone()` | Status is exactly 410. |
| `unprocessableEntity()` | Status is exactly 422. |
| `tooManyRequests()` | Status is exactly 429. |
| `internalServerError()` | Status is exactly 500. |
| `serviceUnavailable()` | Status is exactly 503. |
| `successful()` | Status is 200–299. |
| `redirect()` | Status is 300–399. |
| `failed()` | Status is 400 or greater. |
Expand All @@ -54,5 +80,14 @@ The facade also provides `fake()`, `restore()`, `isFaking()`, `fakeInstance()`,
## JavaScript exports

The module exports `Fetch`, `PendingRequest`, and named helpers for every
configuration and request method. Low-level `bridgeCall`, `start`, and
`downloadNative` exports are available for advanced integrations.
configuration and request method.

## Application-wide events

`FetchRequestStarted`, `FetchRequestCompleted`, and `FetchRequestFailed` can be
consumed either by a NativeComponent using `#[On]` or application-wide using
Laravel's `Event::listen()`. Their constructors and payloads are identical in
both cases.

The remaining progress, retry, cancellation, and download-completed events are
component-only.
4 changes: 4 additions & 0 deletions docs/downloads.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,7 @@ public function downloadCompleted(

Cancelled and failed downloads remove Fetch-owned partial files. Retries begin
again at byte zero; resumable Range downloads are not supported.

Downloads dispatch the globally observable `FetchRequestStarted` event after
native preparation succeeds. Download failures use the globally observable
`FetchRequestFailed` event. `FetchDownloadCompleted` remains component-only.
48 changes: 48 additions & 0 deletions docs/errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Validation and errors

Fetch throws a `Victorycodedev\NativephpFetch\Exceptions\FetchException` when a
request is configured incorrectly or the native bridge rejects the work. These
exceptions are thrown synchronously, before any network activity begins, so you
can catch them at the call site.

## Configuration validation

| Condition | Message |
| --- | --- |
| `timeout()` less than 1 second | `Fetch timeout must be at least 1 second.` |
| `retry()` with negative `times` | `Fetch retry times cannot be negative.` |
| `retry()` with negative `delay` | `Fetch retry delay cannot be negative.` |
| `retry()` with `multiplier` below `1.0` | `Fetch retry multiplier must be at least 1.0.` |
| `retry()` with `maxDelay` below `delay` | `Fetch retry maxDelay must be greater than or equal to delay.` |
| `retry()` with a non-integer or out-of-range status | `Fetch retry statuses must contain valid integer HTTP status codes.` |
| Empty `baseUrl()` | `Fetch base URL cannot be empty.` |
| Empty `withBody()` content type | `Fetch raw body content type cannot be empty.` |

## Body and attachment rules

| Condition | Message |
| --- | --- |
| `asJson()`, `asForm()`, or `withBody()` with existing attachments | `Fetch attachments cannot be combined with {JSON, form, raw body} bodies.` |
| `raw` body combined with method data | `Fetch raw bodies cannot be combined with method data.` |
| `get()` with a body or attachments | `Fetch request bodies cannot be sent with a GET request.` / `Fetch attachments cannot be sent with a GET request.` |
| Empty attachment field name or path | `Fetch attachment field name cannot be empty.` / `Fetch attachment path cannot be empty.` |
| Attachment without a determinable filename | `Fetch could not determine an attachment filename.` |
| Malformed `attachMany()` entries | `Fetch attachment at index {n} ...` |

## Runtime errors

```php
try {
Fetch::post($url, $data);
} catch (Victorycodedev\NativephpFetch\Exceptions\FetchException $e) {
$this->error = $e->getMessage();
}
```

When the NativePHP mobile bridge is unavailable — for example in a queued job,
scheduled task, or CLI command — starting work throws
`Fetch requires the NativePHP Mobile runtime.`

A transport problem after the request is accepted does **not** throw. It is
delivered asynchronously through `FetchRequestFailed` with a `code` and
`message` instead. See [Events and responses](/events).
50 changes: 48 additions & 2 deletions docs/events.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Events and responses

Use NativePHP listeners such as `#[On]` or `$this->on()`. These events come
through NativePHP's bridge; they are not ordinary Laravel global events.
NativePHP component events are handled by the active NativeComponent with
`#[On]` or `$this->on()`. The started, completed, and failed lifecycle events
are also broadcast through Laravel so application-wide listeners may consume
the same event and payload.

## Completion example

Expand Down Expand Up @@ -29,6 +31,47 @@ public function completed(
}
```

## Application-wide Laravel listeners

Register app-wide listeners in an application or event service provider:

```php
use Illuminate\Support\Facades\Event;
use Victorycodedev\NativephpFetch\Events\FetchRequestCompleted;
use Victorycodedev\NativephpFetch\Events\FetchRequestFailed;
use Victorycodedev\NativephpFetch\Events\FetchRequestStarted;

Event::listen(FetchRequestStarted::class, function (FetchRequestStarted $event) {
logger()->debug('Native request started', [
'request_id' => $event->requestId,
'method' => $event->method,
'url' => $event->url,
]);
});

Event::listen(FetchRequestCompleted::class, function (FetchRequestCompleted $event) {
logger()->info('Native request completed', [
'request_id' => $event->requestId,
'status' => $event->status,
]);
});

Event::listen(FetchRequestFailed::class, function (FetchRequestFailed $event) {
logger()->error('Native request failed', [
'request_id' => $event->requestId,
'code' => $event->code,
'message' => $event->message,
]);
});
```

These are the same event classes received by `#[On]`; global broadcasting does
not replace or alter component delivery. `FetchRequestStarted` is dispatched
after native request preparation succeeds and immediately before the first
network attempt begins. It fires once for normal requests and downloads, not
again for internal retries. A validation or preparation failure may go directly
to `FetchRequestFailed` without first dispatching `FetchRequestStarted`.

## Event reference

| Event | Data |
Expand All @@ -42,6 +85,9 @@ public function completed(
| `FetchDownloadProgress` | `requestId`, `bytesReceived`, nullable `bytesTotal`, nullable `progress` |
| `FetchDownloadCompleted` | `requestId`, `status`, `headers`, `path`, `bytesReceived` |

Only `FetchRequestStarted`, `FetchRequestCompleted`, and `FetchRequestFailed`
are globally observable. The other events in the table remain component-only.

## Terminal events

Each request ends with exactly one terminal event: completed, failed,
Expand Down
16 changes: 13 additions & 3 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,23 @@ for or return the HTTP response.
```php
use Victorycodedev\NativephpFetch\Facades\Fetch;

$requestId = Fetch::acceptJson()
->timeout(30)
->get('https://api.example.com/users');
```

The returned value is the request ID. If you need the ID before the network
work is accepted, read it from the pending request first:

```php
$request = Fetch::acceptJson()->timeout(30);
$requestId = $request->id();
$returnedId = $request->get('https://api.example.com/users');
$request->get('https://api.example.com/users');
```

`$requestId` and `$returnedId` are the same. Store the ID before starting work
so even a very fast event can be correlated with the correct request.
Both `$requestId` and the value returned by `get()` are identical. Store the ID
before starting work so even a very fast event can be correlated with the
correct request.

## Runtime scope

Expand Down
43 changes: 43 additions & 0 deletions docs/javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,42 @@ await Fetch.asForm().post(url, { name: 'Victory' });
await Fetch.withBody('<user>Victory</user>', 'application/xml').post(url);
```

## Headers, timeout, and retries

```javascript
const request = Fetch.withHeaders({ 'X-App-Version': '1.0.0' })
.withHeader('X-Trace-ID', traceId)
.withToken(token)
.acceptJson()
.timeout(20)
.retry({ times: 3, delay: 500, multiplier: 2, maxDelay: 30000, statuses: [409] });

const requestId = await request.post(url, data);
```

`retry()` accepts a number for a simple policy or an object for full control.
As in PHP, the promise resolves once native code accepts the work, not with the
HTTP response.

## Request IDs and cancellation

```javascript
const request = Fetch.acceptJson().timeout(30);
const requestId = request.id();

await request.post(url, data);
await Fetch.cancel(requestId);
```

## Downloads

```javascript
const requestId = await Fetch.download(url, destination, {
query: { version: 2 },
overwrite: false,
});
```

## Multiple attachments

```javascript
Expand Down Expand Up @@ -56,3 +92,10 @@ const listener = (payload) => {
On(event, listener);
// Call Off(event, listener) when the component unmounts.
```

## PHP-only features

The JavaScript client has no `baseUrl()` helper; resolve full URLs before
calling. Macros, the fake, and testing assertions are PHP-only. For everything
else the JavaScript client mirrors the PHP facade.

8 changes: 3 additions & 5 deletions docs/native-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,10 @@ class TasksScreen extends NativeComponent
$this->loading = true;
$this->error = null;

$request = Fetch::withToken(config('services.api.token'))
$this->requestId = Fetch::withToken(config('services.api.token'))
->acceptJson()
->timeout(15);

$this->requestId = $request->id();
$request->get('https://api.example.com/tasks', ['limit' => 20]);
->timeout(15)
->get('https://api.example.com/tasks', ['limit' => 20]);
}

public function cancelRequest(): void
Expand Down
Loading
Loading