# Handlers

- [Introduction](#introduction)
- [The DeeplinkHandler Contract](#the-deeplinkhandler-contract)
    - [canHandle](#canhandle)
    - [handle](#handle)
    - [DeeplinkSource](#deeplinksource)
- [Built-in Handlers](#built-in-handlers)
    - [RouteDeeplinkHandler](#routedeeplinkhandler)
    - [Switching Tenants from a Push](#switching-tenants-from-a-push)
    - [Breadcrumb Events](#breadcrumb-events)
    - [OneSignalDeeplinkHandler](#onesignaldeeplinkhandler)
- [Creating Custom Handlers](#creating-custom-handlers)
- [Registering Handlers](#registering-handlers)
- [Handler Execution Order](#handler-execution-order)

## Introduction

Handlers are the core processing unit of the deep link pipeline. When a deep link arrives, the `DeeplinkManager` walks its registered handler list and delegates the URI to the first handler that claims it. This chain-of-responsibility pattern keeps each handler focused on a single concern and makes the pipeline trivially extensible.

Every handler implements the `DeeplinkHandler` contract. The two built-in handlers, `RouteDeeplinkHandler` and `OneSignalDeeplinkHandler`, cover the most common integration scenarios out of the box.

## The DeeplinkHandler Contract

All handlers extend the abstract `DeeplinkHandler` class:

```dart
abstract class DeeplinkHandler {
  bool canHandle(Uri uri);
  Future<bool> handle(
    Uri uri, {
    required DeeplinkSource source,
    Map<String, dynamic>? payload,
  });
}
```

### canHandle

```dart
bool canHandle(Uri uri);
```

A synchronous predicate. Return `true` if this handler wants to process the URI; `false` to pass it to the next handler in the chain. Keep this method cheap: it runs on every incoming link before any handler is invoked.

### handle

```dart
Future<bool> handle(
  Uri uri, {
  required DeeplinkSource source,
  Map<String, dynamic>? payload,
});
```

The async processing method, called only when `canHandle` returned `true`. Return `true` if the URI was successfully handled, `false` if processing failed but should not be retried by another handler. **Never throw** from this method: exceptions break the pipeline. Catch internally and return `false` on failure.

`source` and `payload` travel with the URI all the way from `DeeplinkManager.handleUri()`; see [DeeplinkSource](#deeplinksource) below for what they carry and why a handler should check them before trusting anything beyond the path.

### DeeplinkSource

```dart
enum DeeplinkSource { osLink, push, manual }
```

Where the instruction to open `uri` came from, and the two paths are not equally trusted:

| Value | Meaning | `payload` |
|-------|---------|-----------|
| `osLink` | The operating system opened the app on a Universal Link or App Link. Attacker-craftable: anyone who can get the device to open a URI can produce one of these. | Always `null`. |
| `push` | The user tapped a push notification, and the payload is the server's own. | The full push payload the notification arrived with. |
| `manual` | The application asked for the link itself, in code or in a test. | Whatever the caller passed, or `null`. |

`source` is a required, non-defaulted parameter precisely so a handler cannot forget to ask: a handler that reads `payload` to act on more than the path it was given (switching the user's active team off a `team_id` key, say) may only do that when `source == DeeplinkSource.push`, because that is the one case where the payload was authored by the server rather than crafted into a URI. `RouteDeeplinkHandler` reads them only when it was given a [`tenantGate`](#switching-tenants-from-a-push): navigating to a path the consumer listed is safe regardless of who asked for it, moving the session's tenant is not.

## Built-in Handlers

### RouteDeeplinkHandler

Matches incoming URI paths against a list of patterns and navigates to the matched route using `MagicRoute.to()`.

**Constructor:**

```dart
RouteDeeplinkHandler({
  required List<String> paths,
  List<String>? hosts,
  bool caseSensitive = false,
  TenantSwitchGate? tenantGate,
})
```

`hosts` is optional and defaults to `null`, which keeps the original path-only behaviour: an absolute URI on any host matching one of `paths` is claimed. Set it to gate `canHandle` on the address as well:

- A relative URI (no scheme, no authority, the shape a push payload path carries) is always accepted; there is no host to check.
- An absolute URI is accepted only over `http` or `https`, with no explicit port and no userinfo, and only when its host equals one entry of `hosts` case-insensitively. Anything else (a different host, a non-http(s) scheme, a port, embedded credentials) is refused before the path is even matched.
- A blank entry in `hosts` is ignored rather than treated as a wildcard. `hosts: ['']` refuses every absolute URI, including one whose own host is empty (`https:/incidents/5`); it does not fall back to matching any host.

```dart
final handler = RouteDeeplinkHandler(
  paths: ['/monitors/:id'],
  hosts: ['example.com'],
);

handler.canHandle(Uri.parse('/monitors/5')); // true, relative
handler.canHandle(Uri.parse('https://EXAMPLE.com/monitors/5')); // true, case-insensitive
handler.canHandle(Uri.parse('https://evil.com/monitors/5')); // false, wrong host
handler.canHandle(Uri.parse('https://example.com:8443/monitors/5')); // false, explicit port
```

`caseSensitive` is optional and defaults to `false`, matching go_router's own default of case-sensitive routes only when a consumer opts in: `/incidents/5` and `/INCIDENTS/5` both match a `paths` entry of `/incidents/:id` unless `caseSensitive: true` is passed, in which case only the exact case matches and the mismatched one falls through to the next handler (or to go_router's not-found page if none claims it).

**Pattern syntax:**

| Pattern | Matches |
|---------|---------|
| `/products` | Exactly `/products` |
| `/products/*` | Any single path segment after `/products/` |
| `/products/:id` | A single non-slash segment (named parameter) |
| `/shop/*/detail` | Any path with a wildcard middle segment |

Matching is case-insensitive by default (see `caseSensitive` above) and trailing slashes are normalized before comparison.

**Example:**

```dart
final handler = RouteDeeplinkHandler(
  paths: [
    '/products/:id',
    '/shop/*',
    '/promotions',
  ],
);
```

When a matching URI arrives, the handler calls:

```dart
MagicRoute.to(uri.path, query: uri.queryParameters);
```

Query parameters are forwarded automatically, so `https://example.com/products/42?ref=email` navigates to `/products/42` with `{'ref': 'email'}` available via `Request.query('ref')`. The path navigated is the PARSED `uri.path`, the same value `canHandle` matched, because `Uri` resolves dot segments while parsing.

`handle` never throws. It re-checks `canHandle` and answers `false` for a path it does not serve, and a navigation that fails (a router that is not built yet answers with a `StateError`) is logged through magic's `Log`, when a `log` binding exists, and answered `false`.

### Switching Tenants from a Push

A multi-tenant backend resolves a page against the session's current tenant and answers 404 for another tenant's row. A push paging a user about another team's incident would land on that 404, so `RouteDeeplinkHandler` can switch first when it is given a `TenantSwitchGate`:

```dart
final handler = RouteDeeplinkHandler(
  paths: ['/incidents/:id'],
  tenantGate: TenantSwitchGate(
    // Your app's own team state; any `String?` and any `Future<bool>` will do.
    currentTenantId: () => TeamController.instance.currentTeamId?.toString(),
    switchTenant: (String teamId) => TeamController.instance.switchTeam(teamId),
    onSwitched: () => Magic.success('Switched team', 'Opened on the team it belongs to.'),
    onSwitchFailed: (String teamId) => Magic.error('Error', 'Could not switch team.'),
  ),
);
```

| Field | Meaning |
|-------|---------|
| `payloadKey` | The push payload key naming the owning tenant. Defaults to `'team_id'`. |
| `currentTenantId` | The session's current tenant id, or `null` while it has not resolved. |
| `switchTenant` | Moves the session onto a tenant and answers whether it took. |
| `onSwitched` | Optional. Runs after a successful switch, before the navigation. One that throws is logged and the link still opens, since the session has already moved. |
| `onSwitchFailed` | Optional. Runs with the requested tenant id when `switchTenant` answered `false` or threw. |

The rules are the handler's and no gate configuration changes them:

- **Only `DeeplinkSource.push` switches.** An `osLink` or a `manual` link navigates without switching, whatever its payload or query carries: an OS link is attacker-craftable, and a switch moves the session's scoping (and often its billing) subject.
- **The tenant comes from the payload under `payloadKey`, never from the URI query.** The query is part of the link.
- **Ids compare as trimmed strings**, so a wire `5` and a local `'5'` are the same tenant.
- **No evidence, no switch.** An absent or blank payload tenant, or a `null` current tenant, navigates directly.
- **A failed switch does not navigate.** `switchTenant` answering `false` calls `onSwitchFailed`, logs an error and answers `false`; opening the page anyway would meet the same 404. A `switchTenant` that throws counts as a failed switch: it calls `onSwitchFailed`, is logged, and answers `false` without navigating. An `onSwitched` that throws is logged and the page still opens, because by then the session is already on the new tenant.

### Breadcrumb Events

`RouteDeeplinkHandler` dispatches two magic events through `Event.dispatch`, unawaited, so a failing listener never costs the navigation:

| Event | When | `breadcrumbCategory` | `breadcrumbData` |
|-------|------|----------------------|------------------|
| `DeeplinkOpened` | After `canHandle` passed, before any switch or navigation | `deeplink.open` | `source`, `route`, `names_tenant` |
| `DeeplinkNavigating` | Immediately before `MagicRoute.to` | `deeplink.navigate` | `route` |

Both implement magic's `ReportsBreadcrumb`, so a crash reporter that watches every event with `Event.listenAny` (as `magic_sentry` does) records them without this package depending on it. Dispatching `DeeplinkNavigating` right before the navigation makes it the last breadcrumb on an exception thrown while the destination builds, which tells "the link never resolved" apart from "the page it named threw". `route` is the handler's own pattern that matched (`/invitations/:token/accept`), not the concrete path, and the data never carries the query string or a payload value, since a link can carry an invite or reset token in any of them.

```dart
Event.listenAny((MagicEvent event) {
  if (event is DeeplinkNavigating) {
    debugPrint('opening a link matching ${event.route}');
  }
});
```

### OneSignalDeeplinkHandler

Bridges OneSignal push-notification click events into the deep link pipeline. It does **not** implement `DeeplinkHandler` directly: instead it acts as a listener adapter that extracts a URI from the notification payload and feeds it to the manager.

**URI extraction:**

The handler checks the notification `data` map for these keys in order: `url`, `deep_link`, `link`, `uri`. The first non-empty string value that parses as a valid URI is used.

**Setup:**

```dart
void setup(DeeplinkManager manager, dynamic notifications)
```

Call `setup()` once, passing the manager singleton and the `magic_notifications` notification manager (resolved out of the container, e.g. `app.make('notifications')`). `notifications` is read structurally as `dynamic` so this package declares no dependency on `magic_notifications`. The handler subscribes to the manager's own `onPushClicked` stream, which the manager owns from construction and republishes onto whenever a driver is attached later, so it works whether the notifications provider has booted yet or not. Every matched URI is routed through `manager.handleUri()`.

A notification manager that publishes no `onPushClicked` stream, or an event carrying no readable `data` payload, is reported at error level through magic's `Log` (guarded by `Magic.bound('log')`) rather than thrown or silently dropped.

**Disposal:**

```dart
void dispose()
```

Cancels the internal stream subscription. Call this in your service provider's teardown or when the handler is no longer needed.

**Example:**

```dart
final onesignalHandler = OneSignalDeeplinkHandler();

// Inside DeeplinkServiceProvider.boot(), after app.bound('notifications'):
onesignalHandler.setup(
  DeeplinkManager(),
  app.make('notifications'), // the magic_notifications manager
);
```

## Creating Custom Handlers

Extend `DeeplinkHandler` to handle any URI matching logic your application requires.

```dart
import 'package:magic_deeplink/magic_deeplink.dart';

class ProductDeeplinkHandler extends DeeplinkHandler {
  @override
  bool canHandle(Uri uri) {
    // Only claim URIs with the /products path and a numeric id segment
    final segments = uri.pathSegments;
    return segments.length == 2 &&
        segments[0] == 'products' &&
        int.tryParse(segments[1]) != null;
  }

  @override
  Future<bool> handle(
    Uri uri, {
    required DeeplinkSource source,
    Map<String, dynamic>? payload,
  }) async {
    final id = int.parse(uri.pathSegments[1]);

    try {
      final product = await ProductRepository.find(id);

      if (product == null) {
        MagicRoute.to('/products/not-found');
        return false;
      }

      MagicRoute.to('/products/$id', query: uri.queryParameters);
      return true;
    } catch (_) {
      return false;
    }
  }
}
```

Key rules when implementing a custom handler:

- `canHandle` must be **synchronous** and **side-effect free**.
- `handle` must **never throw**: wrap async operations in try-catch.
- Return `false` (not throw) when the URI cannot be handled after `canHandle` returned `true`.
- Trust `payload` only when `source == DeeplinkSource.push`; an `osLink` carries no payload and its URI is attacker-craftable.

## Registering Handlers

Register handlers on the `DeeplinkManager` singleton via `registerHandler()`. The manager deduplicates registrations: adding the same instance twice has no effect.

```dart
final manager = DeeplinkManager();

manager.registerHandler(
  RouteDeeplinkHandler(paths: ['/products/:id', '/shop/*']),
);

manager.registerHandler(ProductDeeplinkHandler());
```

The recommended place to register handlers is inside `DeeplinkServiceProvider.boot()`, after the driver is initialized:

```dart
class DeeplinkServiceProvider extends ServiceProvider {
  @override
  Future<void> boot() async {
    final manager = app.make<DeeplinkManager>('deeplinks');

    manager.registerHandler(
      RouteDeeplinkHandler(
        paths: app.make<ConfigRepository>('config').get('deeplink.paths'),
      ),
    );

    manager.registerHandler(ProductDeeplinkHandler());
  }
}
```

## Handler Execution Order

Handlers are evaluated in **registration order**. The manager iterates the list and stops at the first handler whose `canHandle` returns `true`:

```dart
Future<bool> handleUri(
  Uri uri, {
  required DeeplinkSource source,
  Map<String, dynamic>? payload,
}) async {
  for (final handler in _handlers) {
    if (handler.canHandle(uri)) {
      return await handler.handle(uri, source: source, payload: payload);
    }
  }
  return false; // No handler claimed the URI
}
```

Practical implications:

- **Register specific handlers before generic ones.** A `RouteDeeplinkHandler` with a wildcard pattern (`/products/*`) will consume URIs before a more specific custom handler if registered first.
- **The manager returns `false`** when no handler claims a URI. Consider registering a catch-all fallback handler last if you want to log or report unhandled links.
- **`handleUri` also emits to `manager.onLink`** before the handler chain runs, so stream listeners always receive every URI regardless of whether a handler claims it.

```dart
// Specific handler first
manager.registerHandler(ProductDeeplinkHandler());

// Generic route handler second: won't shadow the specific one
manager.registerHandler(
  RouteDeeplinkHandler(paths: ['/products/*', '/shop/*']),
);
```
