diff --git a/CHANGELOG.md b/CHANGELOG.md index f6459a0..70166d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 96532ca..5d82644 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 07136f7..7a2cdb9 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -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" }, ], diff --git a/docs/api-reference.md b/docs/api-reference.md index d460671..c71dce7 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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. | @@ -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. | @@ -31,6 +32,11 @@ 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 | @@ -38,13 +44,33 @@ The facade also provides `fake()`, `restore()`, `isFaking()`, `fakeInstance()`, | `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. | @@ -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. diff --git a/docs/downloads.md b/docs/downloads.md index 86f4c5b..c173ef0 100644 --- a/docs/downloads.md +++ b/docs/downloads.md @@ -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. diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..76f614e --- /dev/null +++ b/docs/errors.md @@ -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). diff --git a/docs/events.md b/docs/events.md index 7f7e297..28ec3be 100644 --- a/docs/events.md +++ b/docs/events.md @@ -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 @@ -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 | @@ -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, diff --git a/docs/getting-started.md b/docs/getting-started.md index 51c0d6b..a100f61 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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 diff --git a/docs/javascript.md b/docs/javascript.md index fdca90e..b5cf24b 100644 --- a/docs/javascript.md +++ b/docs/javascript.md @@ -29,6 +29,42 @@ await Fetch.asForm().post(url, { name: 'Victory' }); await Fetch.withBody('Victory', '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 @@ -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. + diff --git a/docs/native-component.md b/docs/native-component.md index 77f7797..a2c4312 100644 --- a/docs/native-component.md +++ b/docs/native-component.md @@ -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 diff --git a/docs/requests.md b/docs/requests.md index 62b0b23..dc691b5 100644 --- a/docs/requests.md +++ b/docs/requests.md @@ -33,13 +33,12 @@ class LoginScreen extends NativeComponent $this->loading = true; $this->error = null; - $request = Fetch::acceptJson()->timeout(30); - - $this->requestId = $request->id(); - $request->post('https://api.example.com/login', [ - 'email' => $this->email, - 'password' => $this->password, - ]); + $this->requestId = Fetch::acceptJson() + ->timeout(30) + ->post('https://api.example.com/login', [ + 'email' => $this->email, + 'password' => $this->password, + ]); } public function cancelLogin(): void @@ -65,7 +64,6 @@ class LoginScreen extends NativeComponent if ($response->successful()) { $token = $response->json('token'); - // Store the token and continue into the application. return; } @@ -133,6 +131,71 @@ Fetch::acceptJson()->delete($url); List query values become repeated query keys. +## Base URLs + +Use `baseUrl` to keep a shared API origin or path prefix out of each request: + +```php +use Victorycodedev\NativephpFetch\Facades\Fetch; + +Fetch::baseUrl('https://api.example.com/v1') + ->acceptJson() + ->get('/users', ['page' => 2]); +``` + +Fetch joins the base URL and relative request path with exactly one slash. An +absolute request URL overrides the configured base URL: + +```php +Fetch::baseUrl('https://api.example.com') + ->get('https://status.example.com/health'); +``` + +`baseUrl` belongs to that pending request only. Query parameters remain +separate from the resolved URL and retain the same native encoding behavior as +requests without a base URL. + +## Macros + +Macros let an application define reusable request presets. Register them once +in your `AppServiceProvider`'s `boot` method: + +```php +baseUrl(config('services.api.url')) + ->acceptJson() + ->withToken(config('services.api.token')) + ->timeout(15); + }); + } +} +``` + +Call the macro through the facade wherever a request is needed: + +```php +use Victorycodedev\NativephpFetch\Facades\Fetch; + +Fetch::api()->get('/users'); +Fetch::api()->post('/users', ['name' => 'Victory']); +``` +Macro closures are bound to the Fetch manager, so methods such as `baseUrl()`, +`withToken()`, and `acceptJson()` create and configure a fresh `PendingRequest` +for each call. This keeps request IDs and fluent configuration isolated. Macro +registrations are static for the lifetime of the PHP process; tests that +register temporary macros may call `Fetch::flushMacros()` during cleanup. + ## Headers and authentication ```php diff --git a/docs/retries-cancellation.md b/docs/retries-cancellation.md index c7a7df6..154ae6f 100644 --- a/docs/retries-cancellation.md +++ b/docs/retries-cancellation.md @@ -22,11 +22,35 @@ Fetch::retry( )->post($url, $data); ``` +- **`times`** — how many retries to make after the initial attempt. `times: 4` + means up to four additional attempts, five total. +- **`delay`** — the base delay in milliseconds before the first retry. Here the + first retry waits 250 ms. +- **`multiplier`** — the exponential backoff factor applied to the delay between + successive attempts. With `multiplier: 1.5` and a base delay of 250 ms, delays + grow as 250 ms, 375 ms, 562 ms, and so on. +- **`maxDelay`** — the upper bound in milliseconds for any single delay. No + computed delay will exceed 5000 ms here. +- **`statuses`** — the HTTP status codes that trigger a retry. This example + retries only on `409` and `425` responses. + +When omitted, the defaults are `times: 3`, `delay: 500`, `multiplier: 2.0`, +and `maxDelay: 30000`. + Default retryable statuses are `408`, `429`, `500`, `502`, `503`, and `504`. A non-empty `statuses` list replaces those statuses. Transient failures with codes `timeout`, `offline`, `dns_failure`, `connection_failed`, and `network_error` remain retryable. +When a server returns a `Retry-After` header, Fetch honors it and uses that +value as the delay instead of the calculated backoff delay. The value is still +capped by `maxDelay`, and the total number of attempts never exceeds +`times + 1`. + +Native scheduling also applies ±20% random jitter to every delay, whether it +comes from the backoff calculation or a `Retry-After` header, before `maxDelay` +is enforced. + The same request ID is retained across attempts. `FetchRequestRetrying` announces the next attempt and scheduled delay. diff --git a/docs/testing.md b/docs/testing.md index 3114bd7..d894c7f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -39,7 +39,25 @@ Fetch::fake([ The fake records events for deterministic assertions through `Fetch::fakeInstance()->events()`. It does not dispatch through Laravel's global -event dispatcher because production events use NativePHP's event bridge. +event dispatcher because production global dispatch occurs when an event +arrives through NativePHP's event bridge. + +`isFaking()` returns whether a fake is currently active. `assertNotSent()` fails +when any recorded request matches the given closure (or when any request was +sent, if no closure is given). + +## RecordedRequest + +The closure passed to `assertSent()` receives a `RecordedRequest`: + +| Method | Description | +| --- | --- | +| `requestId()` | The request's stable ID. | +| `method()` | The HTTP method (`GET` for downloads). | +| `url()` | The resolved request URL. | +| `headers()` | Every configured header. | +| `body()` | The normalized body payload, or `null` when absent. | +| `hasHeader(name, value = null)` | Whether a header exists; if `value` is given, whether it also matches. | Run the package test suite with: diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php index 559c3fd..013ea3f 100644 --- a/resources/boost/guidelines/core.blade.php +++ b/resources/boost/guidelines/core.blade.php @@ -6,8 +6,8 @@ ### Requests Public fluent methods are `request`, `withHeaders`, `withHeader`, `withToken`, -`acceptJson`, `asJson`, `asForm`, `withBody`, `timeout`, `retry`, `attach`, and -`attachMany`. Terminal methods are `get`, `post`, `put`, `patch`, `delete`, +`baseUrl`, `acceptJson`, `asJson`, `asForm`, `withBody`, `timeout`, `retry`, +`attach`, and `attachMany`. Terminal methods are `get`, `post`, `put`, `patch`, `delete`, `download`, and `cancel`; they return a stable request ID (except `cancel`, which returns a boolean), never a synchronous HTTP response. @@ -36,11 +36,22 @@ Fetch::acceptJson()->asJson()->patch("{$url}/{$id}", ['active' => true]); Fetch::acceptJson()->delete("{$url}/{$id}"); Fetch::withHeaders(['X-App' => 'mobile']) - ->withToken($token) - ->get($url); + ->withToken($token) + ->get($url); @endverbatim +Use `Fetch::baseUrl('https://api.example.com/v1')->get('/users')` to resolve a +relative request or download URL. An absolute terminal URL overrides the base. +The query array remains separate in the native bridge payload and native code +appends it after any query already present in the resolved URL. + +The Fetch manager uses Laravel Macroable. Register reusable request presets +with `Victorycodedev\NativephpFetch\Fetch::macro()` in an application service +provider, and have each macro call a fluent manager method such as `baseUrl()` +so it returns a fresh PendingRequest. Macros persist for the PHP process; +temporary test macros should be cleaned up with `Fetch::flushMacros()`. + In a NativeComponent, create the pending request, assign `$request->id()` to component state, then call the terminal method. Listen with `#[On(...)]`, ignore events whose `requestId` does not match, and clear loading state on @@ -56,7 +67,11 @@ Construct `FetchResponse::from($requestId, $status, $headers, $body)` inside a `FetchRequestCompleted` listener. Helpers include `status`, `body`, `headers`, case-insensitive `header`, `json` with dot notation, `ok`, `successful`, -`redirect`, `failed`, `clientError`, and `serverError`. Invalid JSON returns +`redirect`, `failed`, `clientError`, `serverError`, `statusIs`, `created`, +`accepted`, `noContent`, `movedPermanently`, `found`, `badRequest`, +`unauthorized`, `paymentRequired`, `forbidden`, `notFound`, `methodNotAllowed`, +`requestTimeout`, `conflict`, `gone`, `unprocessableEntity`, `tooManyRequests`, +`internalServerError`, and `serviceUnavailable`. Invalid JSON returns the caller's default rather than throwing. ### Downloads diff --git a/src/Events/FetchRequestCompleted.php b/src/Events/FetchRequestCompleted.php index 84a60f1..9ac09b9 100644 --- a/src/Events/FetchRequestCompleted.php +++ b/src/Events/FetchRequestCompleted.php @@ -4,8 +4,9 @@ use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +use Native\Mobile\Events\Concerns\BroadcastsGlobally; -class FetchRequestCompleted +class FetchRequestCompleted implements BroadcastsGlobally { use Dispatchable, SerializesModels; diff --git a/src/Events/FetchRequestFailed.php b/src/Events/FetchRequestFailed.php index 9aa5d9a..a4f805d 100644 --- a/src/Events/FetchRequestFailed.php +++ b/src/Events/FetchRequestFailed.php @@ -4,8 +4,9 @@ use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +use Native\Mobile\Events\Concerns\BroadcastsGlobally; -class FetchRequestFailed +class FetchRequestFailed implements BroadcastsGlobally { use Dispatchable, SerializesModels; diff --git a/src/Events/FetchRequestStarted.php b/src/Events/FetchRequestStarted.php index a0ac287..cc5e56d 100644 --- a/src/Events/FetchRequestStarted.php +++ b/src/Events/FetchRequestStarted.php @@ -4,8 +4,9 @@ use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +use Native\Mobile\Events\Concerns\BroadcastsGlobally; -class FetchRequestStarted +class FetchRequestStarted implements BroadcastsGlobally { use Dispatchable, SerializesModels; diff --git a/src/Facades/Fetch.php b/src/Facades/Fetch.php index 3deeb12..d6f9680 100644 --- a/src/Facades/Fetch.php +++ b/src/Facades/Fetch.php @@ -13,6 +13,11 @@ * @method static void assertSent(\Closure $callback) * @method static void assertNotSent(?\Closure $callback = null) * @method static void assertSentCount(int $count) + * @method static void macro(string $name, object|callable $macro) + * @method static void mixin(object $mixin, bool $replace = true) + * @method static bool hasMacro(string $name) + * @method static void flushMacros() + * @method static PendingRequest baseUrl(string $url) * @method static PendingRequest withHeaders(array $headers) * @method static PendingRequest withHeader(string $name, string $value) * @method static PendingRequest withToken(string $token, string $type = 'Bearer') diff --git a/src/Fetch.php b/src/Fetch.php index 29b0bdb..590b8dd 100644 --- a/src/Fetch.php +++ b/src/Fetch.php @@ -3,10 +3,13 @@ namespace Victorycodedev\NativephpFetch; use Closure; +use Illuminate\Support\Traits\Macroable; use Victorycodedev\NativephpFetch\Testing\FakeFetch; class Fetch { + use Macroable; + protected ?FakeFetch $fake = null; public function fake(array $responses = []): FakeFetch @@ -54,6 +57,11 @@ public function withHeaders(array $headers): PendingRequest return $this->request()->withHeaders($headers); } + public function baseUrl(string $url): PendingRequest + { + return $this->request()->baseUrl($url); + } + public function withHeader(string $name, string $value): PendingRequest { return $this->request()->withHeader($name, $value); diff --git a/src/FetchResponse.php b/src/FetchResponse.php index f851dcf..6f05f55 100644 --- a/src/FetchResponse.php +++ b/src/FetchResponse.php @@ -87,7 +87,102 @@ public function json(?string $key = null, mixed $default = null): mixed public function ok(): bool { - return $this->status === 200; + return $this->statusIs(200); + } + + public function statusIs(int $status): bool + { + return $this->status === $status; + } + + public function created(): bool + { + return $this->statusIs(201); + } + + public function accepted(): bool + { + return $this->statusIs(202); + } + + public function noContent(): bool + { + return $this->statusIs(204); + } + + public function movedPermanently(): bool + { + return $this->statusIs(301); + } + + public function found(): bool + { + return $this->statusIs(302); + } + + public function badRequest(): bool + { + return $this->statusIs(400); + } + + public function unauthorized(): bool + { + return $this->statusIs(401); + } + + public function paymentRequired(): bool + { + return $this->statusIs(402); + } + + public function forbidden(): bool + { + return $this->statusIs(403); + } + + public function notFound(): bool + { + return $this->statusIs(404); + } + + public function methodNotAllowed(): bool + { + return $this->statusIs(405); + } + + public function requestTimeout(): bool + { + return $this->statusIs(408); + } + + public function conflict(): bool + { + return $this->statusIs(409); + } + + public function gone(): bool + { + return $this->statusIs(410); + } + + public function unprocessableEntity(): bool + { + return $this->statusIs(422); + } + + public function tooManyRequests(): bool + { + return $this->statusIs(429); + } + + public function internalServerError(): bool + { + return $this->statusIs(500); + } + + public function serviceUnavailable(): bool + { + return $this->statusIs(503); } public function successful(): bool diff --git a/src/PendingRequest.php b/src/PendingRequest.php index 75c62d6..e346984 100644 --- a/src/PendingRequest.php +++ b/src/PendingRequest.php @@ -15,6 +15,8 @@ class PendingRequest protected array $attachments = []; + protected ?string $baseUrl = null; + protected int $timeout = 30; protected string $bodyMode = 'json'; @@ -33,6 +35,17 @@ public function id(): string return $this->requestId; } + public function baseUrl(string $url): static + { + if (trim($url) === '') { + throw new FetchException('Fetch base URL cannot be empty.'); + } + + $this->baseUrl = rtrim($url, '/'); + + return $this; + } + public function withHeaders(array $headers): static { foreach ($headers as $name => $value) { @@ -68,7 +81,7 @@ public function withToken( ): static { return $this->withHeader( 'Authorization', - trim($type).' '.$token, + trim($type) . ' ' . $token, ); } @@ -354,7 +367,7 @@ public function download( 'Fetch.Download', [ 'request_id' => $requestId, - 'url' => $url, + 'url' => $this->resolveUrl($url), 'destination' => $destination, 'headers' => $this->headers, 'query' => $query, @@ -407,7 +420,7 @@ protected function send( $payload = [ 'request_id' => $requestId, 'method' => strtoupper($method), - 'url' => $url, + 'url' => $this->resolveUrl($url), 'headers' => $this->headers, 'query' => $query, 'timeout' => $this->timeout, @@ -480,6 +493,19 @@ protected function bodyPayload( ]; } + protected function resolveUrl(string $url): string + { + if ($this->baseUrl === null || preg_match('/^[a-z][a-z0-9+.-]*:\/\//i', $url) === 1) { + return $url; + } + + if ($url === '') { + return $this->baseUrl; + } + + return $this->baseUrl . '/' . ltrim($url, '/'); + } + protected function assertNoAttachments(string $mode): void { if ($this->attachments !== []) { @@ -498,7 +524,7 @@ protected function encodeForm(array $data): string is_array($item), is_object($item) => json_encode($item, JSON_THROW_ON_ERROR), default => (string) $item, }; - $pairs[] = urlencode((string) $name).'='.urlencode($normalized); + $pairs[] = urlencode((string) $name) . '=' . urlencode($normalized); } } diff --git a/tests/ContractTest.php b/tests/ContractTest.php index 45cfad1..42a6ed8 100644 --- a/tests/ContractTest.php +++ b/tests/ContractTest.php @@ -6,6 +6,7 @@ it('keeps the manager and pending request public APIs synchronized', function () { $expected = [ 'request', + 'baseUrl', 'withHeaders', 'withHeader', 'withToken', @@ -48,6 +49,7 @@ foreach ([ 'request', + 'baseUrl', 'withHeaders', 'withHeader', 'withToken', diff --git a/tests/DownloadTest.php b/tests/DownloadTest.php index 8b8a745..8294309 100644 --- a/tests/DownloadTest.php +++ b/tests/DownloadTest.php @@ -60,6 +60,33 @@ ->and($GLOBALS['fetch_bridge_calls'][0]['payload']['timeout'])->toBe(30); }); +it('resolves relative download URLs against a request base URL', function () { + (new PendingRequest) + ->baseUrl('https://cdn.example.com/v1/') + ->download( + '/files/report.pdf?download=1', + '/app/downloads/report.pdf', + query: ['version' => 2], + ); + + expect($GLOBALS['fetch_bridge_calls'][0]['payload']['url']) + ->toBe('https://cdn.example.com/v1/files/report.pdf?download=1') + ->and($GLOBALS['fetch_bridge_calls'][0]['payload']['query']) + ->toBe(['version' => 2]); +}); + +it('leaves absolute download URLs unchanged when a base URL is configured', function () { + (new PendingRequest) + ->baseUrl('https://cdn.example.com') + ->download( + 'https://other.example.com/report.pdf', + '/app/downloads/report.pdf', + ); + + expect($GLOBALS['fetch_bridge_calls'][0]['payload']['url']) + ->toBe('https://other.example.com/report.pdf'); +}); + it('supports the direct manager download API', function () { $requestId = (new FetchManager)->download( 'https://example.test/file.pdf', diff --git a/tests/ExtensibilityTest.php b/tests/ExtensibilityTest.php new file mode 100644 index 0000000..07601cc --- /dev/null +++ b/tests/ExtensibilityTest.php @@ -0,0 +1,86 @@ + 'success', + 'accepted' => true, + ]; +}); + +afterEach(function () { + Fetch::flushMacros(); +}); + +it('registers macros that return freshly configured pending requests', function () { + Fetch::macro('api', function () { + return $this->baseUrl('https://api.example.com') + ->acceptJson() + ->withToken('secret') + ->timeout(15); + }); + + $fetch = new Fetch; + $first = $fetch->api(); + $second = $fetch->api(); + + expect(Fetch::hasMacro('api'))->toBeTrue() + ->and($first)->toBeInstanceOf(PendingRequest::class) + ->and($second)->toBeInstanceOf(PendingRequest::class) + ->and($first)->not->toBe($second) + ->and($first->id())->not->toBe($second->id()); + + $first->withHeader('X-First-Only', 'yes')->get('/users'); + $second->post('/users', ['name' => 'Taylor']); + + expect($GLOBALS['fetch_bridge_calls'][0]['payload']) + ->toMatchArray([ + 'url' => 'https://api.example.com/users', + 'method' => 'GET', + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer secret', + 'X-First-Only' => 'yes', + ], + 'timeout' => 15, + ]) + ->and($GLOBALS['fetch_bridge_calls'][1]['payload']) + ->toMatchArray([ + 'url' => 'https://api.example.com/users', + 'method' => 'POST', + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer secret', + ], + 'timeout' => 15, + ]) + ->and($GLOBALS['fetch_bridge_calls'][1]['payload']['headers']) + ->not->toHaveKey('X-First-Only'); +}); + +it('keeps macros compatible with Fetch fakes and resolved base URLs', function () { + Fetch::macro('api', function () { + return $this->baseUrl('https://api.example.com')->acceptJson(); + }); + + $fetch = new Fetch; + $fake = $fetch->fake([ + 'https://api.example.com/users*' => FetchResponse::make(200, ['ok' => true]), + ]); + + $fetch->api()->get('/users', ['page' => 2]); + + expect($fake->requests())->toHaveCount(1); + + $fake->assertSent(function (RecordedRequest $request) { + return $request->url() === 'https://api.example.com/users' + && $request->payload()['query'] === ['page' => 2] + && $request->hasHeader('Accept', 'application/json'); + }); +}); diff --git a/tests/FetchResponseTest.php b/tests/FetchResponseTest.php index f492260..8f67e71 100644 --- a/tests/FetchResponseTest.php +++ b/tests/FetchResponseTest.php @@ -17,6 +17,35 @@ [503, [false, false, false, true, false, true]], ]); +it('matches exact HTTP response status helpers', function (string $method, int $status) { + $matching = FetchResponse::from('id', $status); + $neighbor = FetchResponse::from('id', $status + 1); + + expect($matching->statusIs($status))->toBeTrue() + ->and($matching->statusIs($status + 1))->toBeFalse() + ->and($matching->{$method}())->toBeTrue() + ->and($neighbor->{$method}())->toBeFalse(); +})->with([ + ['created', 201], + ['accepted', 202], + ['noContent', 204], + ['movedPermanently', 301], + ['found', 302], + ['badRequest', 400], + ['unauthorized', 401], + ['paymentRequired', 402], + ['forbidden', 403], + ['notFound', 404], + ['methodNotAllowed', 405], + ['requestTimeout', 408], + ['conflict', 409], + ['gone', 410], + ['unprocessableEntity', 422], + ['tooManyRequests', 429], + ['internalServerError', 500], + ['serviceUnavailable', 503], +]); + it('provides body headers and case insensitive lookup', function () { $response = FetchResponse::from('request-id', 200, ['content-TYPE' => 'application/json', 'X-Many' => ['a', 'b']], '{"data":{"user":{"name":"Victory"}}}'); expect($response->requestId())->toBe('request-id') diff --git a/tests/GlobalEventsTest.php b/tests/GlobalEventsTest.php new file mode 100644 index 0000000..baa641f --- /dev/null +++ b/tests/GlobalEventsTest.php @@ -0,0 +1,125 @@ +setAccessible(true); + $dispatch->invoke($component, $event, $payload); +} + +it('globally broadcasts only the intentionally selected lifecycle events', function () { + foreach ([FetchRequestStarted::class, FetchRequestCompleted::class, FetchRequestFailed::class] as $event) { + expect(is_subclass_of($event, BroadcastsGlobally::class))->toBeTrue(); + } + + foreach ([FetchRequestCancelled::class, FetchRequestRetrying::class, FetchUploadProgress::class, FetchDownloadProgress::class, FetchDownloadCompleted::class] as $event) { + expect(is_subclass_of($event, BroadcastsGlobally::class))->toBeFalse(); + } +}); + +it('preserves the existing event constructors and payloads', function () { + $started = new FetchRequestStarted('request-id', 'GET', 'https://example.com'); + $completed = new FetchRequestCompleted('request-id', 200, ['X-Test' => 'yes'], 'body'); + $failed = new FetchRequestFailed('request-id', 'Offline', 'offline'); + + expect((new ReflectionClass($started))->getConstructor()?->getNumberOfParameters())->toBe(3) + ->and($started->requestId)->toBe('request-id')->and($started->method)->toBe('GET')->and($started->url)->toBe('https://example.com') + ->and((new ReflectionClass($completed))->getConstructor()?->getNumberOfParameters())->toBe(4) + ->and($completed->status)->toBe(200)->and($completed->headers)->toBe(['X-Test' => 'yes'])->and($completed->body)->toBe('body') + ->and((new ReflectionClass($failed))->getConstructor()?->getNumberOfParameters())->toBe(3) + ->and($failed->message)->toBe('Offline')->and($failed->code)->toBe('offline'); +}); + +it('lets NativePHP globally dispatch started completed and failed payloads', function () { + broadcastNativeEvent(FetchRequestStarted::class, ['requestId' => 'started-id', 'method' => 'GET', 'url' => 'https://example.com']); + broadcastNativeEvent(FetchRequestCompleted::class, ['requestId' => 'completed-id', 'status' => 404, 'headers' => ['Content-Type' => 'application/json'], 'body' => '{}']); + broadcastNativeEvent(FetchRequestFailed::class, ['requestId' => 'failed-id', 'message' => 'Timed out.', 'code' => 'timeout']); + + expect($GLOBALS['globally_dispatched_fetch_events'])->toHaveCount(3) + ->and($GLOBALS['globally_dispatched_fetch_events'][0])->toBeInstanceOf(FetchRequestStarted::class) + ->and($GLOBALS['globally_dispatched_fetch_events'][1])->toBeInstanceOf(FetchRequestCompleted::class) + ->and($GLOBALS['globally_dispatched_fetch_events'][2])->toBeInstanceOf(FetchRequestFailed::class); +}); + +it('does not globally dispatch component-only events', function () { + broadcastNativeEvent(FetchRequestCancelled::class, ['requestId' => 'request-id']); + broadcastNativeEvent(FetchDownloadCompleted::class, ['requestId' => 'request-id', 'status' => 200, 'headers' => [], 'path' => '/tmp/file', 'bytesReceived' => 10]); + + expect($GLOBALS['globally_dispatched_fetch_events'])->toBeEmpty(); +}); + +it('keeps component On delivery alongside global dispatch', function () { + $component = new class extends NativeComponent + { + public ?array $completedPayload = null; + + #[On(FetchRequestCompleted::class)] + public function completed(string $requestId, int $status, array $headers, string $body): void + { + $this->completedPayload = compact('requestId', 'status', 'headers', 'body'); + } + }; + $register = new ReflectionMethod(NativeComponent::class, 'registerNativeEventListeners'); + $register->setAccessible(true); + $register->invoke($component); + $dispatch = new ReflectionMethod(NativeComponent::class, 'dispatchNativeEvent'); + $dispatch->setAccessible(true); + $payload = ['requestId' => 'request-id', 'status' => 201, 'headers' => ['X-Test' => 'yes'], 'body' => 'created']; + $dispatch->invoke($component, ['event' => FetchRequestCompleted::class, 'payload' => $payload]); + + expect($component->completedPayload)->toBe($payload) + ->and($GLOBALS['globally_dispatched_fetch_events'])->toHaveCount(1) + ->and($GLOBALS['globally_dispatched_fetch_events'][0])->toBeInstanceOf(FetchRequestCompleted::class); +}); + +it('keeps started once per native operation and outside internal retry functions', function () { + $root = dirname(__DIR__); + $android = file_get_contents($root.'/resources/android/src/FetchFunctions.kt'); + $ios = file_get_contents($root.'/resources/ios/Sources/FetchFunctions.swift'); + + expect(substr_count($android, 'emitStarted('))->toBe(3) + ->and(substr_count($ios, 'emitStarted('))->toBe(4) + ->and(substr($android, strpos($android, 'private fun enqueueStandardAttempt'), 900))->not->toContain('emitStarted(') + ->and(substr($ios, strpos($ios, 'private func startStandardAttempt'), 900))->not->toContain('emitStarted(') + ->and($android)->toContain('method = "GET"') + ->and($ios)->toContain('method: "GET"'); +}); + +it('contains none of the discarded global event architecture', function () { + $root = dirname(__DIR__); + + foreach (['FetchRequestSending', 'FetchResponseReceived', 'FetchConnectionFailed'] as $event) { + expect(file_exists($root."/src/Events/{$event}.php"))->toBeFalse(); + } + + expect(file_exists($root.'/src/Support/GlobalEvents.php'))->toBeFalse() + ->and(file_exists($root.'/src/Testing/FailedConnection.php'))->toBeFalse() + ->and(file_get_contents($root.'/src/PendingRequest.php'))->not->toContain('NativeCallbacks', 'GlobalEvents') + ->and(file_get_contents($root.'/src/Testing/FakeFetch.php'))->not->toContain('GlobalEvents', 'FailedConnection'); +}); diff --git a/tests/PluginTest.php b/tests/PluginTest.php index 6558674..d620506 100644 --- a/tests/PluginTest.php +++ b/tests/PluginTest.php @@ -210,7 +210,8 @@ '# Testing', '## Event reference', '# API reference', - )->not->toContain('Event::listen(FetchRequestCompleted::class'); + 'Event::listen(FetchRequestCompleted::class', + ); }); }); diff --git a/tests/PublicApiTest.php b/tests/PublicApiTest.php index 3a46484..96f14ec 100644 --- a/tests/PublicApiTest.php +++ b/tests/PublicApiTest.php @@ -67,6 +67,71 @@ ]); }); +it('resolves request local base URLs without changing query payloads', function ( + string $baseUrl, + string $url, + string $expected, +) { + (new PendingRequest) + ->baseUrl($baseUrl) + ->get($url, [ + 'page' => 2, + 'active' => true, + 'tags' => ['php', 'mobile'], + 'empty' => null, + ]); + + expect($GLOBALS['fetch_bridge_calls'][0]['payload']['url'])->toBe($expected) + ->and($GLOBALS['fetch_bridge_calls'][0]['payload']['query'])->toBe([ + 'page' => 2, + 'active' => true, + 'tags' => ['php', 'mobile'], + 'empty' => null, + ]); +})->with([ + ['https://api.example.com', '/users', 'https://api.example.com/users'], + ['https://api.example.com/', '/users', 'https://api.example.com/users'], + ['https://api.example.com', 'users', 'https://api.example.com/users'], + ['https://api.example.com/v1', '/users', 'https://api.example.com/v1/users'], + ['https://api.example.com', '/users?sort=name', 'https://api.example.com/users?sort=name'], +]); + +it('leaves absolute URLs unchanged when a base URL is configured', function () { + (new PendingRequest) + ->baseUrl('https://api.example.com/v1') + ->get('https://other.example.com/users?sort=name', ['page' => 2]); + + expect($GLOBALS['fetch_bridge_calls'][0]['payload']['url']) + ->toBe('https://other.example.com/users?sort=name') + ->and($GLOBALS['fetch_bridge_calls'][0]['payload']['query']) + ->toBe(['page' => 2]); +}); + +it('preserves existing URLs and query encoding inputs without a base URL', function () { + (new PendingRequest)->get('https://example.test/users?sort=name', [ + 'search' => 'first & last', + 'tags' => ['a/b', 'c d'], + 'enabled' => false, + 'empty' => null, + ]); + + expect($GLOBALS['fetch_bridge_calls'][0]['payload']['url']) + ->toBe('https://example.test/users?sort=name') + ->and($GLOBALS['fetch_bridge_calls'][0]['payload']['query'])->toBe([ + 'search' => 'first & last', + 'tags' => ['a/b', 'c d'], + 'enabled' => false, + 'empty' => null, + ]); +}); + +it('rejects an empty base URL before bridge execution', function () { + expect(fn () => (new PendingRequest)->baseUrl(' ')) + ->toThrow(FetchException::class, 'base URL cannot be empty'); + + expect($GLOBALS['fetch_bridge_calls'])->toBe([]); +}); + it('replaces request headers case insensitively', function () { (new PendingRequest) ->withHeaders(['Accept' => 'text/plain', 'X-Test' => 'first']) @@ -214,6 +279,7 @@ $manager = new Fetch; expect($manager->request())->toBeInstanceOf(PendingRequest::class) + ->and($manager->baseUrl('https://example.test'))->toBeInstanceOf(PendingRequest::class) ->and($manager->withHeaders([]))->toBeInstanceOf(PendingRequest::class) ->and($manager->withHeader('X', 'Y'))->toBeInstanceOf(PendingRequest::class) ->and($manager->withToken('token'))->toBeInstanceOf(PendingRequest::class)