Functions-architecture
Clean architecture for Firebase CallableRequest integration with type-safe Either<ErrorSchema, SuccessSchema> result handling.
// store/lib/function/core/function_result.dart
typedef FunctionResult<E, R> = Either<E, R>;
// Extension for working with function results
extension FunctionResultX<E, R> on FunctionResult<E, R> {
bool get isSuccess => isRight();
bool get isFailure => isLeft();
E? get error => fold((l) => l, (r) => null);
R? get data => fold((l) => null, (r) => r);
}
// services/lib/function/app_function_service.dart
class AppFunctionService {
static AppFunctionService? _instance;
final FirebaseFunctions functionInstance;
final Duration timeout;
factory AppFunctionService({...}) => _instance ??= AppFunctionService._(...);
/// Generic callable execution with Either result parsing
Future<Either<ErrorSchema, T>> executeCallable<T>({
required String functionName,
required T Function(JsonDef) fromJson,
JsonDef? args,
}) async {
LOG.d('Calling function: $functionName with args: $args');
try {
final callable = functionInstance.httpsCallable(
functionName,
options: HttpsCallableOptions(timeout: timeout),
);
final result = await callable.call(args);
return _parseEitherResponse<T>(result.data, fromJson);
} on FirebaseFunctionsException catch (e) {
LOG.e('FirebaseFunctionsException in $functionName: ${e.code} - ${e.message}');
return Left(_parseFirebaseError(e));
} catch (e, stack) {
LOG.e('Unknown error in $functionName: $e', error: e, stackTrace: stack);
return Left(ErrorSchema.unknownError(e.toString()));
}
}
/// Parse the Either-wrapped JSON response from backend
Either<ErrorSchema, T> _parseEitherResponse<T>(
dynamic data,
T Function(JsonDef) fromJson,
) {
if (data is! Map) {
return Left(ErrorSchema.parseError('Invalid response format'));
}
final json = Map<String, dynamic>.from(data);
final status = json['status'] as String?;
if (status == 'success' && json.containsKey('data')) {
try {
return Right(fromJson(json['data'] as JsonDef));
} catch (e) {
return Left(ErrorSchema.parseError('Failed to parse success data: $e'));
}
} else if (status == 'failure' && json.containsKey('error')) {
try {
return Left(ErrorSchema.fromJson(json['error'] as JsonDef));
} catch (e) {
return Left(ErrorSchema.parseError('Failed to parse error data: $e'));
}
} else {
return Left(ErrorSchema.parseError('Unknown response format'));
}
}
/// Parse FirebaseFunctionsException into ErrorSchema
ErrorSchema _parseFirebaseError(FirebaseFunctionsException e) {
// Try to extract typed error from details
if (e.details != null && e.details is Map) {
try {
final details = Map<String, dynamic>.from(e.details as Map);
// Backend may send error wrapped in details
if (details.containsKey('error')) {
return ErrorSchema.fromJson(details['error'] as JsonDef);
}
// Or the details itself is the error
return ErrorSchema.fromJson(details);
} catch (_) {
// Fall through to generic error
}
}
// Map Firebase error code to ErrorSchema
return ErrorSchema(
errorCode: _mapFirebaseCode(e.code),
title: _getErrorTitle(e.code),
message: e.message ?? 'Unknown Firebase error',
details: e.details,
);
}
String _mapFirebaseCode(String firebaseCode) {
// Map firebase error codes to your app error codes
switch (firebaseCode) {
case 'invalid-argument': return 'INVALID_ARGUMENT';
case 'unauthenticated': return 'UNAUTHENTICATED';
case 'permission-denied': return 'PERMISSION_DENIED';
case 'not-found': return 'NOT_FOUND';
case 'already-exists': return 'ALREADY_EXISTS';
case 'resource-exhausted': return 'RATE_LIMITED';
case 'failed-precondition': return 'FAILED_PRECONDITION';
case 'unavailable': return 'SERVICE_UNAVAILABLE';
case 'deadline-exceeded': return 'TIMEOUT';
case 'internal': return 'INTERNAL_ERROR';
case 'unimplemented': return 'NOT_IMPLEMENTED';
default: return 'UNKNOWN_ERROR';
}
}
String _getErrorTitle(String firebaseCode) {
// User-friendly error titles
switch (firebaseCode) {
case 'invalid-argument': return 'Invalid Request';
case 'unauthenticated': return 'Authentication Required';
case 'permission-denied': return 'Access Denied';
case 'not-found': return 'Not Found';
case 'deadline-exceeded': return 'Request Timeout';
default: return 'Error';
}
}
}
// services/lib/function/app_function.dart
abstract class AppFunction<E, R> {
final AppFunctionService? _service;
const AppFunction({AppFunctionService? service}) : _service = service;
/// Function name (must match backend function name)
@mustBeOverridden
String get name;
/// Deserializer for success result
@mustBeOverridden
R Function(JsonDef) get fromJson;
/// Optional: Override for custom error type parsing
E Function(JsonDef)? get errorFromJson => null;
AppFunctionService get service => _service ?? AppFunctionService();
/// Execute the function with Either result
Future<Either<E, R>> execute({JsonDef? params}) async {
if (errorFromJson != null) {
// Custom error type
return _executeWithCustomError(params);
} else {
// Default ErrorSchema error type
return _executeWithDefaultError(params) as Future<Either<E, R>>;
}
}
Future<Either<ErrorSchema, R>> _executeWithDefaultError(JsonDef? params) {
return service.executeCallable<R>(
functionName: name,
fromJson: fromJson,
args: params,
);
}
Future<Either<E, R>> _executeWithCustomError(JsonDef? params) async {
final result = await service.executeCallable<R>(
functionName: name,
fromJson: fromJson,
args: params,
);
// Convert ErrorSchema to custom error type if needed
return result.fold(
(errorSchema) {
if (errorFromJson != null) {
try {
return Left(errorFromJson!(errorSchema.toJson()));
} catch (e) {
// Fallback if conversion fails
return Left(errorSchema as E);
}
}
return Left(errorSchema as E);
},
(data) => Right(data),
);
}
}
// services/lib/function/stripe/stripe_refresh_account_function.dart
class StripeRefreshAccountFunction extends AppFunction<ErrorSchema, StripeRefreshAccountResponseSchema> {
const StripeRefreshAccountFunction({super.service});
@override
String get name => 'refresh_stripe_account';
@override
StripeRefreshAccountResponseSchema Function(JsonDef) get fromJson =>
StripeRefreshAccountResponseSchema.fromJson;
/// Convenience method with typed parameters
Future<Either<ErrorSchema, StripeRefreshAccountResponseSchema>> refresh({
required String accountId,
}) {
return execute(params: {'account_id': accountId});
}
}
// Usage:
final function = StripeRefreshAccountFunction();
final result = await function.refresh(accountId: 'acct_123');
result.fold(
(error) => showError(error.message),
(data) => handleSuccess(data),
);
// services/lib/function/search/search_function.dart
class SearchFunction extends AppFunction<ErrorSchema, GeneralSearchResponseSchema> {
final SearchFunctionType searchType;
final SearchSortType sortType;
final String? uid;
const SearchFunction({
required this.searchType,
required this.sortType,
this.uid,
super.service,
});
@override
String get name => 'search_${searchType.name}';
@override
GeneralSearchResponseSchema Function(JsonDef) get fromJson =>
GeneralSearchResponseSchema.fromJson;
Future<Either<ErrorSchema, GeneralSearchResponseSchema>> search({
required String queryStr,
int pageNum = 1,
}) {
final schema = SearchRequestSchema(
q: queryStr,
pageNum: pageNum,
searchType: searchType.name,
sortType: sortType.name,
autoComplete: false,
uid: uid,
);
return execute(params: schema.toJson());
}
}
// Usage:
final searchFn = SearchFunction(
searchType: SearchFunctionType.dare,
sortType: SearchSortType.recent,
);
final result = await searchFn.search(queryStr: 'test', pageNum: 1);
result.fold(
(error) {
LOG.e('Search failed: ${error.message}');
return [];
},
(data) => data.results,
);
// store/lib/function/schema/error_schema.dart
@freezed
class ErrorSchema with _$ErrorSchema {
const factory ErrorSchema({
@JsonKey(name: 'error_code') required String errorCode,
required String title,
required String message,
@JsonKey(name: 'details') dynamic details,
@JsonKey(name: 'retry_after') int? retryAfter,
}) = _ErrorSchema;
factory ErrorSchema.fromJson(JsonDef json) => _$ErrorSchemaFromJson(json);
// Factory constructors for common errors
factory ErrorSchema.unknownError([String? msg]) => ErrorSchema(
errorCode: 'UNKNOWN_ERROR',
title: 'Unknown Error',
message: msg ?? 'An unknown error occurred',
);
factory ErrorSchema.parseError(String msg) => ErrorSchema(
errorCode: 'PARSE_ERROR',
title: 'Parse Error',
message: msg,
);
factory ErrorSchema.networkError() => const ErrorSchema(
errorCode: 'NETWORK_ERROR',
title: 'Network Error',
message: 'Failed to connect to server',
);
}
- Type Safety: Full type safety with generics
AppFunction<ErrorType, SuccessType> - Consistent Error Handling: All errors flow through ErrorSchema
- Clean API: Either pattern provides clean success/failure handling
- Testability: Easy to mock and test
- Extensibility: Easy to add new functions
- Server Alignment: Matches backend Either pattern exactly
- Update ErrorSchema in store package
- Refactor AppFunctionService with executeCallable method
- Update AppFunction base class with Either support
- Migrate existing functions (SearchFunction, etc.) one by one
- Update calling code to use Either pattern
void main() {
test('StripeRefreshAccountFunction success', () async {
final mockService = MockAppFunctionService();
when(mockService.executeCallable<StripeRefreshAccountResponseSchema>(
functionName: 'refresh_stripe_account',
fromJson: any,
args: any,
)).thenAnswer((_) async => Right(mockSuccessResponse));
final function = StripeRefreshAccountFunction(service: mockService);
final result = await function.refresh(accountId: 'test');
expect(result.isRight(), true);
});
}