endpoint_lens 0.1.0
endpoint_lens: ^0.1.0 copied to clipboard
A Flutter developer tool that reveals the API endpoints connected to UI elements using long press inspection.
API Lens #
API Lens is a Flutter developer tool that shows which backend endpoint is behind a piece of UI.
Long press an instrumented widget in a running app and see the method, path, status code, and duration of the calls that feature made. It is meant for Flutter developers, backend developers, QA, and testers who otherwise have to search the codebase to answer "what API powers this?"
API Lens does not guess. Flutter cannot see that a Bloc, repository, or Provider issued a request for a particular widget. You tag the call with a feature id, and you wrap the widget that should reveal that feature.
Why use it? #
- Inspect a screen on a device without opening the repository.
- See every endpoint a feature uses, including repeats and failures.
- Copy a redacted cURL command that does not contain tokens, cookies, or passwords.
- Ship the same code in a QA APK and a production APK. A compile-time flag turns the tool off.
Version 0.1.0 records Dio calls. The storage and inspector are client-agnostic so package:http, Retrofit, Chopper, GraphQL, and WebSockets can be added later.
Installation #
Step 1 #
dependencies:
endpoint_lens: ^0.1.0
import 'package:endpoint_lens/endpoint_lens.dart';
The package identifier is endpoint_lens. The API stays ApiLens, ApiLensWidget, and ApiLensDioInterceptor.
Quick start #
Step 2 #
Call init before runApp. The flag is compile-time, so production binaries stay dark unless you opt in.
void main() {
ApiLens.init(
enabled: const bool.fromEnvironment(
'API_LENS',
defaultValue: false,
),
);
runApp(const MyApp());
}
No ApiLensApp wrapper is required. The inspector uses the Navigator you already have.
bool.fromEnvironment is fixed when the app is compiled. Hot reload does not pick up a new --dart-define. Stop the app and run it again.
API Lens does not enable itself just because kDebugMode is true. QA builds are often release builds. Pass the flag explicitly. If you want it on for every local debug session and off for release, this is a reasonable variant:
ApiLens.init(
enabled: const bool.fromEnvironment(
'API_LENS',
defaultValue: kDebugMode,
),
);
A release QA APK still needs --dart-define=API_LENS=true, because kDebugMode is false there.
Step 3 #
dio.interceptors.add(ApiLensDioInterceptor());
Add it before any interceptor that completes the request itself, such as a mock. Dio only delivers that response to the interceptors already in the chain when you pass true:
handler.resolve(response, true);
handler.reject(error, true);
Without true, Dio skips every response interceptor, including API Lens, and the call is not stored.
Step 4 #
ApiLensWidget(
feature: 'profile',
child: ProfileCard(),
)
Step 5 #
ApiLens.track(
feature: 'profile',
request: () => dio.get('/users/1'),
);
Then long press the ProfileCard.
Inspector mode starts enabled when the package is enabled, so the card has a subtle border and the long press is live. ApiLens.disableInspector() or the floating button turns that off without uninstalling the interceptor.
Dio setup #
One interceptor is enough for the whole app:
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com/v1'));
dio.interceptors.add(ApiLensDioInterceptor());
Each stored call keeps:
- HTTP method
- full URL, base URL, and path
- query parameters
- status code
- start time, finish time, and duration
- success or failure
Response bodies are never stored. Request headers and bodies are redacted first.
Tagging calls #
Put ApiLens.track around the code that actually calls Dio. Every awaited Dio call inside that callback inherits the feature, so one screen can record many endpoints:
Future<Profile> loadProfile() {
return ApiLens.track(feature: 'profile', request: () async {
final user = await dio.get('/users/1');
final posts = await dio.get('/users/1/posts');
final avatar = await dio.get('/users/1/avatar');
return Profile(user.data, posts.data, avatar.data);
});
}
There is also a short extension:
await dio.apiLens('products', (dio) => dio.get('/products'));
For a single call that cannot sit inside track, set the feature on that request. This wins over the current zone and still works when the call is scheduled later by Bloc or Riverpod:
dio.get('/users/1', options: ApiLens.options('profile'));
The same tag is the extra value api_lens_feature if you are generating the request with Retrofit or Chopper and only need the key.
ApiLensWidget and ApiLensScope also publish the feature to the subtree. From a button that calls Dio directly:
ApiLens.trackContext(context, () => repository.refresh());
How linking works #
ApiLensWidget(feature: profile)
|
| ApiLens.track(feature: 'profile', ...)
v
Dio --> ApiLensDioInterceptor --> redaction --> in-memory history
|
+--> long press opens the inspector for that feature
A feature is a string you choose, such as profile or checkout. It is not a widget type and not a route name unless you want it to be.
One feature stores many endpoints. Repeats of the same method and path are grouped:
profile
├── GET /users/1
├── GET /users/1/posts
└── GET /users/1/avatar
Eight calls to GET /notifications show as one row with Calls: 8, plus the latest status and duration. Open the row for the individual history.
What is not automatic #
ApiLens.track uses a Dart zone. Zones follow async/await that starts inside the callback. They do not follow:
BlocorCubithandlers (add()returns before the handler runs)- Riverpod providers that are created or rebuilt outside the callback
- isolates,
compute, and callbacks registered earlier on the root zone
Tag the repository method that calls Dio, or pass ApiLens.options. Wrapping bloc.add is not reliable, and the package will not pretend that it is.
ApiLensScope is the same feature binding without a long-press target, for a page whose only inspectable control is a smaller ApiLensWidget.
Long press inspector #
The sheet shows the feature name and the APIs used by that feature. Newest activity is first. Search filters by method or path. Chips filter GET, POST, PUT, PATCH, and DELETE.
API Lens
Feature:
User Profile
APIs Used:
GET /users/123
200
220 ms
GET /users/123/posts
200
491 ms
GET /users/123/avatar
304
103 ms
Tap a row for the detail sheet:
Method:
GET
Endpoint:
/users/123
Full URL:
https://api.example.com/v1/users/123
Status:
200
Duration:
220 ms
Last Called:
12:30:21
Actions:
- Copy Endpoint
- Copy Full URL
- Copy cURL
- Copy details
Generated cURL uses the redacted URL, headers, and body. Secrets are replaced with [REDACTED]. Response bodies are not included.
Failed calls stay in the list and are marked:
POST /auth/login
401
Failed
231 ms
The error label is the Dio exception type (badResponse, connectionError, and so on). The response body is not shown.
Clear history removes only the feature you are looking at. ApiLens.clear() drops everything.
The sheet follows the app Theme, including dark mode.
Inspector mode #
ApiLens.enableInspector();
ApiLens.disableInspector();
ApiLens.toggleInspector();
When inspector mode is on, each ApiLensWidget draws a border and a small feature label. Long press opens API Lens. When it is off, those widgets are quiet again. Recording continues either way, as long as the package is enabled.
ApiLens.init(enabled: true) turns inspector mode on. Pass inspectorEnabled: false to record without the borders until you toggle them.
const ApiLensInspectorButton()
Place the button under your Navigator, for example beside the shell. It floats in the root overlay, can be dragged, and toggles inspector mode. It is not built at all when API Lens is disabled.
Do not put it in MaterialApp.builder. That builder is above the navigator and has no overlay.
QA APK #
flutter build apk --dart-define=API_LENS=true
flutter build ios --dart-define=API_LENS=true
Production safety #
flutter build apk --dart-define=API_LENS=false
With the quick-start default (defaultValue: false), a normal production build is already off. You do not need the false define unless you changed the default.
When enabled is false:
ApiLensWidgetreturns its child unchanged- the inspector cannot open and the floating button is absent
- the Dio interceptor returns immediately
ApiLens.trackjust runs your callback- no request history is stored
Overhead in that mode is a couple of boolean checks per request and no extra widget listeners.
init clears any history already collected. The in-memory list is the only store. Nothing is written to disk.
Redaction #
Defaults always stay on. Lists you pass are added to them, so a custom list cannot accidentally turn off Authorization or password.
ApiLens.init(
enabled: true,
redactHeaders: <String>['X-Custom-Secret'],
redactFields: <String>['pin'],
);
Headers, matched without case sensitivity, include Authorization, Cookie, Set-Cookie, Proxy-Authorization, X-Api-Key, and common token headers.
Body and query fields include password, token, access_token, refresh_token, id_token, api_key, secret, client_secret, and private_key. accessToken and refreshToken are treated as the same fields. Nested maps and lists are walked. URL user info is removed. Query values whose names match a sensitive field become [REDACTED].
Opaque non-JSON request bodies are omitted rather than copied, so a raw token is not kept. A form body such as password=secret&email=a@b.c is parsed and redacted.
Path segments are kept. Do not put secrets in the path. User ids in /users/123 are visible, which is what you want when identifying an endpoint.
The placeholder is [REDACTED] (apiLensRedacted).
Request history #
History is a bounded in-memory list. The default cap is 200 calls. The oldest calls are dropped.
ApiLens.init(
enabled: true,
maxHistory: 200,
);
maxHistory must be at least 1.
Calls without a feature are skipped so background traffic does not fill the buffer. Opt in with captureUntagged: true to keep them under the feature id untagged.
final calls = ApiLens.historyFor('profile'); // newest first
final groups = ApiLens.groupsFor('profile');
ApiLens.clear(feature: 'profile');
ApiLensRequest exposes id, feature, method, endpoint, fullUrl, baseUrl, queryParameters, statusCode, startedAt, finishedAt, duration, success, and a redacted body. toJson() is safe to paste into a ticket.
Status codes from 200 through 399, including 304, are success. Transport failures have a null status and success == false.
Screenshots #
Run the example and long press a card to capture the inspector list, the detail sheet, and the highlight border.
Example #
cd example
flutter run
The example defaults API_LENS to true so the borders show immediately. It includes:
- Login —
POST /login(passwordwrongreturns 401) - Products —
GET /products - Profile —
GET /users/1,GET /users/1/posts,GET /users/1/avatar
Responses come from a mock interceptor. No network is required. Use the floating button to hide the borders, then long press a card again after turning them back on. Refresh a screen to watch the repeat count climb.
API reference examples #
ApiLens.init(
enabled: const bool.fromEnvironment('API_LENS', defaultValue: false),
inspectorEnabled: true,
maxHistory: 200,
redactHeaders: <String>['X-Custom-Secret'],
redactFields: <String>['pin'],
);
ApiLens.enableInspector();
ApiLens.disableInspector();
ApiLens.toggleInspector();
dio.interceptors.add(ApiLensDioInterceptor());
await ApiLens.track(
feature: 'profile',
request: () => dio.get('/users/1'),
);
dio.get('/users/1', options: ApiLens.options('profile'));
ApiLensWidget(
feature: 'profile',
label: 'User Profile',
child: const ProfileCard(),
);
ApiLensScope(
feature: 'profile',
child: const ProfilePage(),
);
final curl = ApiLens.curlFor(ApiLens.historyFor('profile').first);
ApiLens.recordCall is the adapter entry point. Pass the raw headers and body. The package redacts them. Do not pass a response body. There is no field for one.
Roadmap #
package:httpadapter- Retrofit and Chopper helpers that set
api_lens_feature - GraphQL operation names
- WebSocket frames
- A global untagged console
- Optional on-device export of the redacted history
Publishing notes #
pubspec.yaml does not set repository or issue_tracker yet, because the GitHub repository has not been created. Create a repository named endpoint-lens, then add its real URL before publishing.