Networking
Introduction
Nylo Website makes networking simple. You define API endpoints in service classes that extend NyApiService, then call them from your pages. The framework handles JSON decoding, error handling, caching, and automatic conversion of responses to your model classes (called "morphing").
Your API services live in lib/app/networking/. A fresh project includes a default ApiService:
class ApiService extends NyApiService {
ApiService({BuildContext? buildContext})
: super(
buildContext,
decoders: modelDecoders,
);
@override
String get baseUrl => getEnv('API_BASE_URL');
@override
Map<Type, Interceptor> get interceptors => {
...super.interceptors,
};
Future fetchUsers() async {
return await network(
request: (request) => request.get("/users"),
);
}
}
There are three ways to make HTTP requests:
| Approach | Returns | Best For |
|---|---|---|
Convenience methods (get, post, etc.) |
T? |
Simple CRUD operations |
network() |
T? |
Requests needing caching, retries, or custom headers |
networkResponse() |
NyResponse<T> |
When you need status codes, headers, or error details |
Under the hood, Nylo Website uses Dio, a powerful HTTP client.
Convenience Methods
NyApiService provides shorthand methods for common HTTP operations. These call network() internally.
GET Request
Future<User?> fetchUser(int id) async {
return await get<User>(
"/users/$id",
queryParameters: {"include": "profile"},
);
}
POST Request
Future<User?> createUser(Map<String, dynamic> data) async {
return await post<User>("/users", data: data);
}
PUT Request
Future<User?> updateUser(int id, Map<String, dynamic> data) async {
return await put<User>("/users/$id", data: data);
}
DELETE Request
Future<bool?> deleteUser(int id) async {
return await delete<bool>("/users/$id");
}
PATCH Request
Future<User?> patchUser(int id, Map<String, dynamic> data) async {
return await patch<User>("/users/$id", data: data);
}
HEAD Request
Use HEAD to check resource existence or get headers without downloading the body:
Future<bool> checkResourceExists(String url) async {
Response response = await head(url);
return response.statusCode == 200;
}
Network Helper
The network method gives you more control than the convenience methods. It returns the morphed data (T?) directly.
class ApiService extends NyApiService {
...
Future<User?> fetchUser(int id) async {
return await network<User>(
request: (request) => request.get("/users/$id"),
);
}
Future<List<User>?> fetchUsers() async {
return await network<List<User>>(
request: (request) => request.get("/users"),
);
}
Future<User?> createUser(Map<String, dynamic> data) async {
return await network<User>(
request: (request) => request.post("/users", data: data),
);
}
}
The request callback receives a Dio instance with your base URL and interceptors already configured.
network Parameters
| Parameter | Type | Description |
|---|---|---|
request |
Function(Dio) |
The HTTP request to perform (required) |
bearerToken |
String? |
Bearer token for this request |
baseUrl |
String? |
Override the service base URL |
headers |
Map<String, dynamic>? |
Additional headers |
retry |
int? |
Number of retry attempts |
retryDelay |
Duration? |
Delay between retries |
retryIf |
bool Function(DioException)? |
Condition for retrying |
connectionTimeout |
Duration? |
Connection timeout |
receiveTimeout |
Duration? |
Receive timeout |
sendTimeout |
Duration? |
Send timeout |
cacheKey |
String? |
Cache key |
cacheDuration |
Duration? |
Cache duration |
cachePolicy |
CachePolicy? |
Cache strategy |
checkConnectivity |
bool? |
Check connectivity before request |
handleSuccess |
Function(NyResponse<T>)? |
Success callback |
handleFailure |
Function(NyResponse<T>)? |
Failure callback |
networkResponse Helper
Use networkResponse when you need access to the full response — status codes, headers, error messages — not just the data. It returns an NyResponse<T> instead of T?.
Use networkResponse when you need to:
- Check HTTP status codes for specific handling
- Access response headers
- Get detailed error messages for user feedback
- Implement custom error handling logic
Future<NyResponse<User>> fetchUser(int id) async {
return await networkResponse<User>(
request: (request) => request.get("/users/$id"),
);
}
Then use the response in your page:
NyResponse<User> response = await _apiService.fetchUser(1);
if (response.isSuccessful) {
User? user = response.data;
print('Status: ${response.statusCode}');
} else {
print('Error: ${response.errorMessage}');
print('Status: ${response.statusCode}');
}
network vs networkResponse
// network() — returns the data directly
User? user = await network<User>(
request: (request) => request.get("/users/1"),
);
// networkResponse() — returns the full response
NyResponse<User> response = await networkResponse<User>(
request: (request) => request.get("/users/1"),
);
User? user = response.data;
int? status = response.statusCode;
Both methods accept the same parameters. Choose networkResponse when you need to inspect the response beyond just the data.
NyResponse
NyResponse<T> wraps the Dio response with morphed data and status helpers.
Properties
| Property | Type | Description |
|---|---|---|
response |
Response? |
Original Dio Response |
data |
T? |
Morphed/decoded data |
rawData |
dynamic |
Raw response data |
headers |
Headers? |
Response headers |
statusCode |
int? |
HTTP status code |
statusMessage |
String? |
HTTP status message |
contentType |
String? |
Content type from headers |
errorMessage |
String? |
Extracted error message |
Status Checks
| Getter | Description |
|---|---|
isSuccessful |
Status 200-299 |
isClientError |
Status 400-499 |
isServerError |
Status 500-599 |
isRedirect |
Status 300-399 |
hasData |
Data is not null |
isUnauthorized |
Status 401 |
isForbidden |
Status 403 |
isNotFound |
Status 404 |
isTimeout |
Status 408 |
isConflict |
Status 409 |
isRateLimited |
Status 429 |
Data Helpers
NyResponse<User> response = await apiService.fetchUser(1);
// Get data or throw if null
User user = response.dataOrThrow('User not found');
// Get data or use a fallback
User user = response.dataOr(User.guest());
// Run callback only if successful
String? greeting = response.ifSuccessful((user) => 'Hello ${user.name}');
// Pattern match on success/failure
String result = response.when(
success: (user) => 'Welcome, ${user.name}!',
failure: (response) => 'Error: ${response.statusMessage}',
);
// Get a specific header
String? authHeader = response.getHeader('Authorization');
Base Options
Configure default Dio options for your API service using the baseOptions parameter:
class ApiService extends NyApiService {
ApiService({BuildContext? buildContext}) : super(
buildContext,
decoders: modelDecoders,
baseOptions: (BaseOptions baseOptions) {
return baseOptions
..connectTimeout = Duration(seconds: 5)
..sendTimeout = Duration(seconds: 5)
..receiveTimeout = Duration(seconds: 5);
},
);
...
}
You can also configure options dynamically on an instance:
apiService.setConnectTimeout(Duration(seconds: 10));
apiService.setReceiveTimeout(Duration(seconds: 30));
apiService.setSendTimeout(Duration(seconds: 10));
apiService.setContentType('application/json');
Click here to view all the base options you can set.
Adding Headers
Per-Request Headers
Future fetchWithHeaders() async => await network(
request: (request) => request.get("/test"),
headers: {
"Authorization": "Bearer aToken123",
"Device": "iPhone"
}
);
Bearer Token
Future fetchUser() async => await network(
request: (request) => request.get("/user"),
bearerToken: "hello-world-123",
);
Service-Level Headers
apiService.setHeaders({"X-Custom-Header": "value"});
apiService.setBearerToken("my-token");
RequestHeaders Extension
The RequestHeaders type (a Map<String, dynamic> typedef) provides helper methods:
@override
Future<RequestHeaders> setAuthHeaders(RequestHeaders headers) async {
String? token = Auth.data(field: 'token');
if (token != null) {
headers.addBearerToken(token);
}
headers.addHeader('X-App-Version', '1.0.0');
return headers;
}
| Method | Description |
|---|---|
addBearerToken(token) |
Set the Authorization: Bearer header |
getBearerToken() |
Read the bearer token from headers |
addHeader(key, value) |
Add a custom header |
hasHeader(key) |
Check if a header exists |
Uploading Files
Single File Upload
Future<UploadResponse?> uploadAvatar(String filePath) async {
return await upload<UploadResponse>(
'/upload',
filePath: filePath,
fieldName: 'avatar',
additionalFields: {'userId': '123'},
onProgress: (sent, total) {
double progress = sent / total * 100;
print('Progress: ${progress.toStringAsFixed(0)}%');
},
);
}
Multiple File Upload
Future<UploadResponse?> uploadDocuments() async {
return await uploadMultiple<UploadResponse>(
'/upload',
files: {
'avatar': '/path/to/avatar.jpg',
'document': '/path/to/doc.pdf',
},
additionalFields: {'userId': '123'},
onProgress: (sent, total) {
print('Progress: ${(sent / total * 100).toStringAsFixed(0)}%');
},
);
}
Downloading Files
Future<void> downloadFile(String url, String savePath) async {
await download(
url,
savePath: savePath,
onProgress: (received, total) {
if (total != -1) {
print('Progress: ${(received / total * 100).toStringAsFixed(0)}%');
}
},
deleteOnError: true,
);
}
Interceptors
Interceptors let you modify requests before they're sent, handle responses, and manage errors. They run on every request made through the API service.
Use interceptors when you need to:
- Add authentication headers to all requests
- Log requests and responses for debugging
- Transform request/response data globally
- Handle specific error codes (e.g., refresh tokens on 401)
class ApiService extends NyApiService {
ApiService({BuildContext? buildContext}) : super(buildContext, decoders: modelDecoders);
@override
Map<Type, Interceptor> get interceptors => {
...super.interceptors,
BearerAuthInterceptor: BearerAuthInterceptor(),
LoggingInterceptor: LoggingInterceptor(),
};
...
}
Creating a Custom Interceptor
metro make:interceptor logging
File: app/networking/dio/interceptors/logging_interceptor.dart
import 'package:nylo_framework/nylo_framework.dart';
class LoggingInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
print('REQUEST[${options.method}] => PATH: ${options.path}');
return super.onRequest(options, handler);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
print('RESPONSE[${response.statusCode}] => PATH: ${response.requestOptions.path}');
handler.next(response);
}
@override
void onError(DioException dioException, ErrorInterceptorHandler handler) {
print('ERROR[${dioException.response?.statusCode}] => PATH: ${dioException.requestOptions.path}');
handler.next(dioException);
}
}
Network Logger
Nylo Website includes a built-in NetworkLogger interceptor. It is enabled by default when APP_DEBUG is true in your environment.
Configuration
class ApiService extends NyApiService {
ApiService({BuildContext? buildContext}) : super(
buildContext,
decoders: modelDecoders,
useNetworkLogger: true,
networkLogger: NetworkLogger(
logLevel: LogLevelType.verbose,
request: true,
requestHeader: true,
requestBody: true,
responseBody: true,
responseHeader: false,
error: true,
),
);
}
You can disable it by setting useNetworkLogger: false.
class ApiService extends NyApiService {
ApiService({BuildContext? buildContext})
: super(
buildContext,
decoders: modelDecoders,
useNetworkLogger: false, // <-- Disable logger
);
Log Levels
| Level | Description |
|---|---|
LogLevelType.verbose |
Print all request/response details |
LogLevelType.minimal |
Print method, URL, status, and time only |
LogLevelType.none |
No logging output |
Filtering Logs
NetworkLogger(
filter: (options, args) {
// Only log requests to specific endpoints
return options.path.contains('/api/v1');
},
)
Using an API Service
There are two ways to call your API service from a page.
Direct Instantiation
class _MyHomePageState extends NyPage<MyHomePage> {
ApiService _apiService = ApiService();
@override
get init => () async {
List<User>? users = await _apiService.fetchUsers();
print(users);
};
}
Using the api() Helper
The api helper creates instances using your apiDecoders from config/decoders.dart:
class _MyHomePageState extends NyPage<MyHomePage> {
@override
get init => () async {
User? user = await api<ApiService>((request) => request.fetchUser());
print(user);
};
}
With callbacks:
await api<ApiService>(
(request) => request.fetchUser(),
onSuccess: (response, data) {
// data is the morphed User? instance
},
onError: (DioException dioException) {
// Handle the error
},
);
api() Helper Parameters
| Parameter | Type | Description |
|---|---|---|
request |
Function(T) |
The API request function |
context |
BuildContext? |
Build context |
headers |
Map<String, dynamic> |
Additional headers |
bearerToken |
String? |
Bearer token |
baseUrl |
String? |
Override base URL |
page |
int? |
Pagination page |
perPage |
int? |
Items per page |
retry |
int |
Retry attempts |
retryDelay |
Duration? |
Delay between retries |
onSuccess |
Function(Response, dynamic)? |
Success callback |
onError |
Function(DioException)? |
Error callback |
cacheKey |
String? |
Cache key |
cacheDuration |
Duration? |
Cache duration |
Create an API Service
To create a new API service:
metro make:api_service user
With a model:
metro make:api_service user --model="User"
This creates an API service with CRUD methods:
class UserApiService extends NyApiService {
...
Future<List<User>?> fetchAll({dynamic query}) async {
return await network<List<User>>(
request: (request) => request.get("/endpoint-path", queryParameters: query),
);
}
Future<User?> find({required int id}) async {
return await network<User>(
request: (request) => request.get("/endpoint-path/$id"),
);
}
Future<User?> create({required dynamic data}) async {
return await network<User>(
request: (request) => request.post("/endpoint-path", data: data),
);
}
Future<User?> update({dynamic query}) async {
return await network<User>(
request: (request) => request.put("/endpoint-path", queryParameters: query),
);
}
Future<bool?> delete({required int id}) async {
return await network<bool>(
request: (request) => request.delete("/endpoint-path/$id"),
);
}
}
Morphing JSON to Models
"Morphing" is Nylo Website's term for automatically converting JSON responses into your Dart model classes. When you use network<User>(...), the response JSON is passed through your decoder to create a User instance — no manual parsing needed.
class ApiService extends NyApiService {
ApiService({BuildContext? buildContext}) : super(buildContext, decoders: modelDecoders);
// Returns a single User
Future<User?> fetchUser() async {
return await network<User>(
request: (request) => request.get("/user/1"),
);
}
// Returns a List of Users
Future<List<User>?> fetchUsers() async {
return await network<List<User>>(
request: (request) => request.get("/users"),
);
}
}
The decoders are defined in lib/bootstrap/decoders.dart:
final Map<Type, dynamic> modelDecoders = {
User: (data) => User.fromJson(data),
List<User>: (data) =>
List.from(data).map((json) => User.fromJson(json)).toList(),
};
The type parameter you pass to network<T>() is matched against your modelDecoders map to find the right decoder.
See also: Decoders for details on registering model decoders.
Caching Responses
Cache responses to reduce API calls and improve performance. Caching is useful for data that doesn't change frequently, like country lists, categories, or configuration.
Provide a cacheKey and optional cacheDuration:
Future<List<Country>> fetchCountries() async {
return await network<List<Country>>(
request: (request) => request.get("/countries"),
cacheKey: "app_countries",
cacheDuration: const Duration(hours: 1),
) ?? [];
}
Clearing Cache
// Clear a specific cache key
await apiService.clearCache("app_countries");
// Clear all API cache
await apiService.clearAllCache();
Caching with the api() Helper
api<ApiService>(
(request) => request.fetchCountries(),
cacheKey: "app_countries",
cacheDuration: const Duration(hours: 1),
);
Cache Policies
Use CachePolicy for fine-grained control over caching behavior:
| Policy | Description |
|---|---|
CachePolicy.networkOnly |
Always fetch from network (default) |
CachePolicy.cacheFirst |
Try cache first, fallback to network |
CachePolicy.networkFirst |
Try network first, fallback to cache |
CachePolicy.cacheOnly |
Only use cache, error if empty |
CachePolicy.staleWhileRevalidate |
Return cache immediately, update in background |
Usage
Future<List<Country>> fetchCountries() async {
return await network<List<Country>>(
request: (request) => request.get("/countries"),
cacheKey: "app_countries",
cacheDuration: const Duration(hours: 1),
cachePolicy: CachePolicy.staleWhileRevalidate,
) ?? [];
}
When to Use Each Policy
- cacheFirst — Data that rarely changes. Returns cached data instantly, only fetches from network if cache is empty.
- networkFirst — Data that should be fresh when possible. Tries network first, falls back to cache on failure.
- staleWhileRevalidate — UI that needs an immediate response but should stay updated. Returns cached data, then refreshes in the background.
- cacheOnly — Offline mode. Throws an error if no cached data exists.
Note: If you provide a
cacheKeyorcacheDurationwithout specifying acachePolicy, the default policy iscacheFirst.
Retrying Failed Requests
Automatically retry requests that fail.
Basic Retry
Future fetchUsers() async {
return await network(
request: (request) => request.get("/users"),
retry: 3,
);
}
Retry with Delay
Future fetchUsers() async {
return await network(
request: (request) => request.get("/users"),
retry: 3,
retryDelay: Duration(seconds: 2),
);
}
Conditional Retry
Future fetchUsers() async {
return await network(
request: (request) => request.get("/users"),
retry: 3,
retryIf: (DioException dioException) {
// Only retry on server errors
return dioException.response?.statusCode == 500;
},
);
}
Service-Level Retry
apiService.setRetry(3);
apiService.setRetryDelay(Duration(seconds: 2));
apiService.setRetryIf((dioException) => dioException.response?.statusCode == 500);
Connectivity Checks
Fail fast when the device is offline instead of waiting for a timeout.
Service-Level
class ApiService extends NyApiService {
ApiService({BuildContext? buildContext}) : super(buildContext, decoders: modelDecoders);
@override
bool get checkConnectivityBeforeRequest => true;
...
}
Per-Request
await network(
request: (request) => request.get("/users"),
checkConnectivity: true,
);
Dynamic
apiService.setCheckConnectivityBeforeRequest(true);
When enabled and the device is offline:
networkFirstpolicy falls back to cache- Other policies throw
DioExceptionType.connectionErrorimmediately
Cancel Tokens
Manage and cancel pending requests.
// Create a managed cancel token
final token = apiService.createCancelToken();
await apiService.get('/endpoint', cancelToken: token);
// Cancel all pending requests (e.g., on logout)
apiService.cancelAllRequests('User logged out');
// Check active request count
int count = apiService.activeRequestCount;
// Clean up a specific token when done
apiService.removeCancelToken(token);
Setting Auth Headers
Override setAuthHeaders to attach authentication headers to every request. This method is called before each request when shouldSetAuthHeaders is true (the default).
class ApiService extends NyApiService {
...
@override
Future<RequestHeaders> setAuthHeaders(RequestHeaders headers) async {
String? myAuthToken = Auth.data(field: 'token');
if (myAuthToken != null) {
headers.addBearerToken(myAuthToken);
}
return headers;
}
}
Disabling Auth Headers
For public endpoints that don't need authentication:
// Per-request
await network(
request: (request) => request.get("/public-endpoint"),
shouldSetAuthHeaders: false,
);
// Service-level
apiService.setShouldSetAuthHeaders(false);
See also: Authentication for details on authenticating users and storing tokens.
Refreshing Tokens
Override shouldRefreshToken and refreshToken to handle token expiry. These are called before every request.
class ApiService extends NyApiService {
...
@override
Future<bool> shouldRefreshToken() async {
// Check if the token needs refreshing
return false;
}
@override
Future<void> refreshToken(Dio dio) async {
// Use the fresh Dio instance (no interceptors) to refresh the token
dynamic response = (await dio.post("https://example.com/refresh-token")).data;
// Save the new token to storage
await Auth.set((data) {
data['token'] = response['token'];
return data;
});
}
}
The dio parameter in refreshToken is a new Dio instance, separate from the service's main instance, to avoid interceptor loops.
Singleton API Service
By default, the api helper creates a new instance each time. To use a singleton, pass an instance instead of a factory in config/decoders.dart:
final Map<Type, dynamic> apiDecoders = {
ApiService: () => ApiService(), // New instance each time
ApiService: ApiService(), // Singleton — same instance always
};
Advanced Configuration
Custom Dio Initialization
class ApiService extends NyApiService {
ApiService({BuildContext? buildContext}) : super(
buildContext,
decoders: modelDecoders,
initDio: (Dio dio) {
dio.options.validateStatus = (status) => status! < 500;
return dio;
},
);
}
Accessing the Dio Instance
Dio dioInstance = apiService.dio;
Response response = await dioInstance.request(
'/custom-endpoint',
options: Options(method: 'OPTIONS'),
);
Pagination Helper
apiService.setPagination(
1,
paramPage: 'page',
paramPerPage: 'per_page',
perPage: '20',
);
Event Callbacks
apiService.onSuccess((response, data) {
print('Success: ${response.statusCode}');
});
apiService.onError((dioException) {
print('Error: ${dioException.message}');
});
Overridable Properties
| Property | Type | Default | Description |
|---|---|---|---|
baseUrl |
String |
"" |
Base URL for all requests |
interceptors |
Map<Type, Interceptor> |
{} |
Dio interceptors |
decoders |
Map<Type, dynamic>? |
{} |
Model decoders for JSON morphing |
shouldSetAuthHeaders |
bool |
true |
Whether to call setAuthHeaders before requests |
retry |
int |
0 |
Default retry attempts |
retryDelay |
Duration |
1 second |
Default delay between retries |
checkConnectivityBeforeRequest |
bool |
false |
Check connectivity before requests |