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:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user