feat: Update API endpoint usage in repositories to remove redundant path property

- Refactored multiple repository implementations across client and staff features to directly use endpoint objects without accessing the `path` property.
- Introduced a new `FeatureGate` class for client-side feature gating based on user scopes, allowing for better access control to API endpoints.
- Added `ApiEndpoint` class to represent API endpoints with their paths and required scopes for future feature gating.
This commit is contained in:
Achintha Isuru
2026-03-17 12:01:06 -04:00
parent 57bba8ab4e
commit 376b4e4431
47 changed files with 240 additions and 139 deletions

View File

@@ -20,8 +20,8 @@ export 'src/services/api_service/dio_client.dart';
export 'src/services/api_service/mixins/api_error_handler.dart';
export 'src/services/api_service/mixins/session_handler_mixin.dart';
// API Endpoint classes
export 'src/services/api_service/api_endpoint.dart';
// Feature Gate & Endpoint classes
export 'src/services/api_service/feature_gate.dart';
export 'src/services/api_service/endpoints/auth_endpoints.dart';
export 'src/services/api_service/endpoints/client_endpoints.dart';
export 'src/services/api_service/endpoints/core_endpoints.dart';

View File

@@ -1,16 +0,0 @@
/// Represents an API endpoint with its path and required scopes for future
/// feature gating.
class ApiEndpoint {
/// Creates an [ApiEndpoint] with the given [path] and optional
/// [requiredScopes].
const ApiEndpoint(this.path, {this.requiredScopes = const <String>[]});
/// The relative URL path (e.g. '/auth/client/sign-in').
final String path;
/// Scopes required to access this endpoint. Empty means no gate.
final List<String> requiredScopes;
@override
String toString() => path;
}

View File

@@ -1,10 +1,11 @@
import 'package:dio/dio.dart';
import 'package:krow_domain/krow_domain.dart';
import 'feature_gate.dart';
/// A service that handles HTTP communication using the [Dio] client.
///
/// This class provides a wrapper around [Dio]'s methods to handle
/// response parsing and error handling in a consistent way.
/// Integrates [FeatureGate] to validate endpoint scopes before each request.
class ApiService implements BaseApiService {
/// Creates an [ApiService] with the given [Dio] instance.
ApiService(this._dio);
@@ -15,12 +16,13 @@ class ApiService implements BaseApiService {
/// Performs a GET request to the specified [endpoint].
@override
Future<ApiResponse> get(
String endpoint, {
ApiEndpoint endpoint, {
Map<String, dynamic>? params,
}) async {
FeatureGate.instance.validateAccess(endpoint);
try {
final Response<dynamic> response = await _dio.get<dynamic>(
endpoint,
endpoint.path,
queryParameters: params,
);
return _handleResponse(response);
@@ -32,13 +34,14 @@ class ApiService implements BaseApiService {
/// Performs a POST request to the specified [endpoint].
@override
Future<ApiResponse> post(
String endpoint, {
ApiEndpoint endpoint, {
dynamic data,
Map<String, dynamic>? params,
}) async {
FeatureGate.instance.validateAccess(endpoint);
try {
final Response<dynamic> response = await _dio.post<dynamic>(
endpoint,
endpoint.path,
data: data,
queryParameters: params,
);
@@ -51,13 +54,14 @@ class ApiService implements BaseApiService {
/// Performs a PUT request to the specified [endpoint].
@override
Future<ApiResponse> put(
String endpoint, {
ApiEndpoint endpoint, {
dynamic data,
Map<String, dynamic>? params,
}) async {
FeatureGate.instance.validateAccess(endpoint);
try {
final Response<dynamic> response = await _dio.put<dynamic>(
endpoint,
endpoint.path,
data: data,
queryParameters: params,
);
@@ -70,13 +74,14 @@ class ApiService implements BaseApiService {
/// Performs a PATCH request to the specified [endpoint].
@override
Future<ApiResponse> patch(
String endpoint, {
ApiEndpoint endpoint, {
dynamic data,
Map<String, dynamic>? params,
}) async {
FeatureGate.instance.validateAccess(endpoint);
try {
final Response<dynamic> response = await _dio.patch<dynamic>(
endpoint,
endpoint.path,
data: data,
queryParameters: params,
);
@@ -89,13 +94,14 @@ class ApiService implements BaseApiService {
/// Performs a DELETE request to the specified [endpoint].
@override
Future<ApiResponse> delete(
String endpoint, {
ApiEndpoint endpoint, {
dynamic data,
Map<String, dynamic>? params,
}) async {
FeatureGate.instance.validateAccess(endpoint);
try {
final Response<dynamic> response = await _dio.delete<dynamic>(
endpoint,
endpoint.path,
data: data,
queryParameters: params,
);
@@ -105,6 +111,10 @@ class ApiService implements BaseApiService {
}
}
// ---------------------------------------------------------------------------
// Response handling
// ---------------------------------------------------------------------------
/// Extracts [ApiResponse] from a successful [Response].
ApiResponse _handleResponse(Response<dynamic> response) {
return ApiResponse(

View File

@@ -26,7 +26,7 @@ class FileUploadService extends BaseCoreService {
if (category != null) 'category': category,
});
return api.post(CoreEndpoints.uploadFile.path, data: formData);
return api.post(CoreEndpoints.uploadFile, data: formData);
});
if (res.code.startsWith('2')) {

View File

@@ -19,7 +19,7 @@ class LlmService extends BaseCoreService {
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreEndpoints.invokeLlm.path,
CoreEndpoints.invokeLlm,
data: <String, dynamic>{
'prompt': prompt,
if (responseJsonSchema != null)

View File

@@ -19,7 +19,7 @@ class RapidOrderService extends BaseCoreService {
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreEndpoints.transcribeRapidOrder.path,
CoreEndpoints.transcribeRapidOrder,
data: <String, dynamic>{
'audioFileUri': audioFileUri,
'locale': locale,
@@ -51,7 +51,7 @@ class RapidOrderService extends BaseCoreService {
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreEndpoints.parseRapidOrder.path,
CoreEndpoints.parseRapidOrder,
data: <String, dynamic>{
'text': text,
'locale': locale,

View File

@@ -17,7 +17,7 @@ class SignedUrlService extends BaseCoreService {
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreEndpoints.createSignedUrl.path,
CoreEndpoints.createSignedUrl,
data: <String, dynamic>{
'fileUri': fileUri,
'expiresInSeconds': expiresInSeconds,

View File

@@ -22,7 +22,7 @@ class VerificationService extends BaseCoreService {
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreEndpoints.verifications.path,
CoreEndpoints.verifications,
data: <String, dynamic>{
'type': type,
'subjectType': subjectType,
@@ -44,7 +44,7 @@ class VerificationService extends BaseCoreService {
/// Polls the status of a specific verification.
Future<VerificationResponse> getStatus(String verificationId) async {
final ApiResponse res = await action(() async {
return api.get(CoreEndpoints.verificationStatus(verificationId).path);
return api.get(CoreEndpoints.verificationStatus(verificationId));
});
if (res.code.startsWith('2')) {
@@ -65,7 +65,7 @@ class VerificationService extends BaseCoreService {
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreEndpoints.verificationReview(verificationId).path,
CoreEndpoints.verificationReview(verificationId),
data: <String, dynamic>{
'decision': decision,
if (note != null) 'note': note,
@@ -84,7 +84,7 @@ class VerificationService extends BaseCoreService {
/// Retries a verification job that failed or needs re-processing.
Future<VerificationResponse> retryVerification(String verificationId) async {
final ApiResponse res = await action(() async {
return api.post(CoreEndpoints.verificationRetry(verificationId).path);
return api.post(CoreEndpoints.verificationRetry(verificationId));
});
if (res.code.startsWith('2')) {

View File

@@ -1,4 +1,4 @@
import 'package:krow_core/src/services/api_service/api_endpoint.dart';
import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
/// Authentication endpoints for both staff and client apps.
abstract final class AuthEndpoints {

View File

@@ -1,4 +1,4 @@
import 'package:krow_core/src/services/api_service/api_endpoint.dart';
import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
/// Client-specific API endpoints (read and write).
abstract final class ClientEndpoints {

View File

@@ -1,4 +1,4 @@
import 'package:krow_core/src/services/api_service/api_endpoint.dart';
import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
/// Core infrastructure endpoints (upload, signed URLs, LLM, verifications,
/// rapid orders).

View File

@@ -1,4 +1,4 @@
import 'package:krow_core/src/services/api_service/api_endpoint.dart';
import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
/// Staff-specific API endpoints (read and write).
abstract final class StaffEndpoints {

View File

@@ -0,0 +1,69 @@
import 'package:flutter/foundation.dart';
import 'package:krow_domain/krow_domain.dart';
/// Client-side feature gate that checks user scopes against endpoint
/// requirements before allowing an API call.
///
/// Usage:
/// ```dart
/// FeatureGate.instance.validateAccess(StaffEndpoints.dashboard);
/// ```
///
/// When an endpoint's [ApiEndpoint.requiredScopes] is empty, access is always
/// granted. When scopes are defined, the gate verifies that the user has ALL
/// required scopes. Throws [InsufficientScopeException] if any are missing.
class FeatureGate {
FeatureGate._();
/// The global singleton instance.
static final FeatureGate instance = FeatureGate._();
/// The scopes the current user has.
List<String> _userScopes = const <String>[];
/// Updates the user's scopes (call after sign-in or session hydration).
void setUserScopes(List<String> scopes) {
_userScopes = List<String>.unmodifiable(scopes);
debugPrint('[FeatureGate] User scopes updated: $_userScopes');
}
/// Clears the user's scopes (call on sign-out).
void clearScopes() {
_userScopes = const <String>[];
debugPrint('[FeatureGate] User scopes cleared');
}
/// The current user's scopes (read-only).
List<String> get userScopes => _userScopes;
/// Returns `true` if the user has all scopes required by [endpoint].
bool hasAccess(ApiEndpoint endpoint) {
if (endpoint.requiredScopes.isEmpty) return true;
return endpoint.requiredScopes.every(
(String scope) => _userScopes.contains(scope),
);
}
/// Validates that the user can access [endpoint].
///
/// No-op when the endpoint has no required scopes (ungated).
/// Throws [InsufficientScopeException] when scopes are missing.
void validateAccess(ApiEndpoint endpoint) {
if (endpoint.requiredScopes.isEmpty) return;
final List<String> missingScopes = endpoint.requiredScopes
.where((String scope) => !_userScopes.contains(scope))
.toList();
if (missingScopes.isNotEmpty) {
throw InsufficientScopeException(
requiredScopes: endpoint.requiredScopes,
userScopes: _userScopes,
technicalMessage:
'Endpoint "${endpoint.path}" requires scopes '
'${endpoint.requiredScopes} but user has $_userScopes. '
'Missing: $missingScopes',
);
}
}
}

View File

@@ -51,7 +51,7 @@ class V2SessionService with SessionHandlerMixin {
return null;
}
final ApiResponse response = await api.get(AuthEndpoints.session.path);
final ApiResponse response = await api.get(AuthEndpoints.session);
if (response.data is Map<String, dynamic>) {
final Map<String, dynamic> data =
@@ -99,7 +99,7 @@ class V2SessionService with SessionHandlerMixin {
final BaseApiService? api = _apiService;
if (api != null) {
try {
await api.post(AuthEndpoints.signOut.path);
await api.post(AuthEndpoints.signOut);
} catch (e) {
debugPrint('[V2SessionService] Server sign-out failed: $e');
}