Skip to content

Commit 87812fc

Browse files
committed
feat(data): implement model-specific GET filtering
- Implemented filtering for headlines - Implemented filtering for sources - Implemented filtering for categories - Implemented filtering for countries - Added query parameter validation
1 parent 5bc883d commit 87812fc

File tree

1 file changed

+190
-147
lines changed

1 file changed

+190
-147
lines changed

routes/api/v1/data/index.dart

Lines changed: 190 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,55 @@ Future<Response> onRequest(RequestContext context) async {
4949
}
5050

5151
// --- GET Handler ---
52-
/// Handles GET requests: Retrieves all items for the specified model
53-
/// (with optional query/pagination). Includes request metadata in response.
52+
/// Handles GET requests: Retrieves all items for the specified model.
53+
///
54+
/// This handler implements model-specific filtering rules:
55+
/// - **Headlines (`model=headline`):**
56+
/// - Filterable by `q` (text query on title & description).
57+
/// If `q` is present, `categories` and `sources` are ignored.
58+
/// Example: `/api/v1/data?model=headline&q=Dart+Frog`
59+
/// - OR by a combination of:
60+
/// - `categories` (comma-separated category IDs).
61+
/// Example: `/api/v1/data?model=headline&categories=catId1,catId2`
62+
/// - `sources` (comma-separated source IDs).
63+
/// Example: `/api/v1/data?model=headline&sources=sourceId1`
64+
/// - Both `categories` and `sources` can be used together (AND logic).
65+
/// Example: `/api/v1/data?model=headline&categories=catId1&sources=sourceId1`
66+
/// - Other parameters for headlines (e.g., `countries`) will result in a 400 Bad Request.
67+
///
68+
/// - **Sources (`model=source`):**
69+
/// - Filterable by `q` (text query on name & description).
70+
/// If `q` is present, `countries`, `sourceTypes`, `languages` are ignored.
71+
/// Example: `/api/v1/data?model=source&q=Tech+News`
72+
/// - OR by a combination of:
73+
/// - `countries` (comma-separated country ISO codes for `source.headquarters.iso_code`).
74+
/// Example: `/api/v1/data?model=source&countries=US,GB`
75+
/// - `sourceTypes` (comma-separated `SourceType` enum string values for `source.sourceType`).
76+
/// Example: `/api/v1/data?model=source&sourceTypes=blog,news_agency`
77+
/// - `languages` (comma-separated language codes for `source.language`).
78+
/// Example: `/api/v1/data?model=source&languages=en,fr`
79+
/// - These specific filters are ANDed if multiple are provided.
80+
/// - Other parameters for sources will result in a 400 Bad Request.
81+
///
82+
/// - **Categories (`model=category`):**
83+
/// - Filterable ONLY by `q` (text query on name & description).
84+
/// Example: `/api/v1/data?model=category&q=Technology`
85+
/// - Other parameters for categories will result in a 400 Bad Request.
86+
///
87+
/// - **Countries (`model=country`):**
88+
/// - Filterable ONLY by `q` (text query on name & isoCode).
89+
/// Example: `/api/v1/data?model=country&q=United`
90+
/// Example: `/api/v1/data?model=country&q=US`
91+
/// - Other parameters for countries will result in a 400 Bad Request.
92+
///
93+
/// - **Other Models (User, UserAppSettings, UserContentPreferences, AppConfig):**
94+
/// - Currently support exact match for top-level query parameters passed directly.
95+
/// - No specific complex filtering logic (like `_in` or `_contains`) is applied
96+
/// by this handler for these models yet. The `HtDataInMemoryClient` can
97+
/// process such queries if the `specificQueryForClient` map is constructed
98+
/// with the appropriate keys by this handler in the future.
99+
///
100+
/// Includes request metadata in the response.
54101
Future<Response> _handleGet(
55102
RequestContext context,
56103
String modelName,
@@ -59,182 +106,178 @@ Future<Response> _handleGet(
59106
PermissionService permissionService,
60107
String requestId,
61108
) async {
62-
// Authorization check is handled by authorizationMiddleware before this.
63-
// This handler only needs to perform the ownership check if required.
64-
65-
// Read query parameters
66109
final queryParams = context.request.uri.queryParameters;
67110
final startAfterId = queryParams['startAfterId'];
68111
final limitParam = queryParams['limit'];
69112
final limit = limitParam != null ? int.tryParse(limitParam) : null;
70-
final specificQuery = Map<String, dynamic>.from(queryParams)
71-
..remove('model')
72-
..remove('startAfterId')
73-
..remove('limit');
74113

75-
// Process based on model type
76-
PaginatedResponse<dynamic> paginatedResponse;
114+
final specificQueryForClient = <String, String>{};
115+
final Set<String> allowedKeys;
116+
final receivedKeys =
117+
queryParams.keys.where((k) => k != 'model' && k != 'startAfterId' && k != 'limit').toSet();
77118

78-
// Determine userId for repository call based on ModelConfig (for data scoping)
79-
String? userIdForRepoCall;
80-
// If the model is user-owned, pass the authenticated user's ID to the repository
81-
// for filtering. Otherwise, pass null.
82-
// Note: This is for data *scoping* by the repository, not the permission check.
83-
// We infer user-owned based on the presence of getOwnerId function.
84-
if (modelConfig.getOwnerId != null) {
85-
userIdForRepoCall = authenticatedUser.id;
86-
} else {
87-
userIdForRepoCall = null;
119+
switch (modelName) {
120+
case 'headline':
121+
allowedKeys = {'categories', 'sources', 'q'};
122+
final qValue = queryParams['q'];
123+
if (qValue != null && qValue.isNotEmpty) {
124+
specificQueryForClient['title_contains'] = qValue;
125+
specificQueryForClient['description_contains'] = qValue;
126+
} else {
127+
if (queryParams.containsKey('categories')) {
128+
specificQueryForClient['category.id_in'] = queryParams['categories']!;
129+
}
130+
if (queryParams.containsKey('sources')) {
131+
specificQueryForClient['source.id_in'] = queryParams['sources']!;
132+
}
133+
}
134+
break;
135+
case 'source':
136+
allowedKeys = {'countries', 'sourceTypes', 'languages', 'q'};
137+
final qValue = queryParams['q'];
138+
if (qValue != null && qValue.isNotEmpty) {
139+
specificQueryForClient['name_contains'] = qValue;
140+
specificQueryForClient['description_contains'] = qValue;
141+
} else {
142+
if (queryParams.containsKey('countries')) {
143+
specificQueryForClient['headquarters.iso_code_in'] = queryParams['countries']!;
144+
}
145+
if (queryParams.containsKey('sourceTypes')) {
146+
specificQueryForClient['source_type_in'] = queryParams['sourceTypes']!;
147+
}
148+
if (queryParams.containsKey('languages')) {
149+
specificQueryForClient['language_in'] = queryParams['languages']!;
150+
}
151+
}
152+
break;
153+
case 'category':
154+
allowedKeys = {'q'};
155+
final qValue = queryParams['q'];
156+
if (qValue != null && qValue.isNotEmpty) {
157+
specificQueryForClient['name_contains'] = qValue;
158+
specificQueryForClient['description_contains'] = qValue;
159+
}
160+
break;
161+
case 'country':
162+
allowedKeys = {'q'};
163+
final qValue = queryParams['q'];
164+
if (qValue != null && qValue.isNotEmpty) {
165+
specificQueryForClient['name_contains'] = qValue;
166+
specificQueryForClient['iso_code_contains'] = qValue; // Also search iso_code
167+
}
168+
break;
169+
default:
170+
// For other models, pass through all non-standard query params directly.
171+
// No specific validation of allowed keys for these other models here.
172+
// The client will attempt exact matches.
173+
allowedKeys = receivedKeys; // Effectively allows all received keys
174+
queryParams.forEach((key, value) {
175+
if (key != 'model' && key != 'startAfterId' && key != 'limit') {
176+
specificQueryForClient[key] = value;
177+
}
178+
});
179+
break;
180+
}
181+
182+
// Validate received keys against allowed keys for the specific models
183+
if (modelName == 'headline' ||
184+
modelName == 'source' ||
185+
modelName == 'category' ||
186+
modelName == 'country') {
187+
for (final key in receivedKeys) {
188+
if (!allowedKeys.contains(key)) {
189+
throw BadRequestException(
190+
'Invalid query parameter "$key" for model "$modelName". '
191+
'Allowed parameters are: ${allowedKeys.join(', ')}.',
192+
);
193+
}
194+
}
88195
}
89196

90-
// Repository exceptions (like NotFoundException, BadRequestException)
91-
// will propagate up to the errorHandler.
197+
PaginatedResponse<dynamic> paginatedResponse;
198+
String? userIdForRepoCall = modelConfig.getOwnerId != null ? authenticatedUser.id : null;
199+
200+
// Repository calls using specificQueryForClient
92201
switch (modelName) {
93202
case 'headline':
94203
final repo = context.read<HtDataRepository<Headline>>();
95-
paginatedResponse = specificQuery.isNotEmpty
96-
? await repo.readAllByQuery(
97-
specificQuery,
98-
userId: userIdForRepoCall,
99-
startAfterId: startAfterId,
100-
limit: limit,
101-
)
102-
: await repo.readAll(
103-
userId: userIdForRepoCall,
104-
startAfterId: startAfterId,
105-
limit: limit,
106-
);
204+
paginatedResponse = await repo.readAllByQuery(
205+
specificQueryForClient,
206+
userId: userIdForRepoCall,
207+
startAfterId: startAfterId,
208+
limit: limit,
209+
);
210+
break;
107211
case 'category':
108212
final repo = context.read<HtDataRepository<Category>>();
109-
paginatedResponse = specificQuery.isNotEmpty
110-
? await repo.readAllByQuery(
111-
specificQuery,
112-
userId: userIdForRepoCall,
113-
startAfterId: startAfterId,
114-
limit: limit,
115-
)
116-
: await repo.readAll(
117-
userId: userIdForRepoCall,
118-
startAfterId: startAfterId,
119-
limit: limit,
120-
);
213+
paginatedResponse = await repo.readAllByQuery(
214+
specificQueryForClient,
215+
userId: userIdForRepoCall,
216+
startAfterId: startAfterId,
217+
limit: limit,
218+
);
219+
break;
121220
case 'source':
122221
final repo = context.read<HtDataRepository<Source>>();
123-
paginatedResponse = specificQuery.isNotEmpty
124-
? await repo.readAllByQuery(
125-
specificQuery,
126-
userId: userIdForRepoCall,
127-
startAfterId: startAfterId,
128-
limit: limit,
129-
)
130-
: await repo.readAll(
131-
userId: userIdForRepoCall,
132-
startAfterId: startAfterId,
133-
limit: limit,
134-
);
222+
paginatedResponse = await repo.readAllByQuery(
223+
specificQueryForClient,
224+
userId: userIdForRepoCall,
225+
startAfterId: startAfterId,
226+
limit: limit,
227+
);
228+
break;
135229
case 'country':
136230
final repo = context.read<HtDataRepository<Country>>();
137-
paginatedResponse = specificQuery.isNotEmpty
138-
? await repo.readAllByQuery(
139-
specificQuery,
140-
userId: userIdForRepoCall,
141-
startAfterId: startAfterId,
142-
limit: limit,
143-
)
144-
: await repo.readAll(
145-
userId: userIdForRepoCall,
146-
startAfterId: startAfterId,
147-
limit: limit,
148-
);
231+
paginatedResponse = await repo.readAllByQuery(
232+
specificQueryForClient,
233+
userId: userIdForRepoCall,
234+
startAfterId: startAfterId,
235+
limit: limit,
236+
);
237+
break;
149238
case 'user':
150239
final repo = context.read<HtDataRepository<User>>();
151-
// Note: While readAll/readAllByQuery is used here for consistency
152-
// with the generic endpoint, fetching a specific user by ID via
153-
// the /data/[id] route is the semantically preferred method.
154-
// The userIdForRepoCall ensures scoping to the authenticated user
155-
// if the repository supports it.
156-
paginatedResponse = specificQuery.isNotEmpty
157-
? await repo.readAllByQuery(
158-
specificQuery,
159-
userId: userIdForRepoCall,
160-
startAfterId: startAfterId,
161-
limit: limit,
162-
)
163-
: await repo.readAll(
164-
userId: userIdForRepoCall,
165-
startAfterId: startAfterId,
166-
limit: limit,
167-
);
240+
paginatedResponse = await repo.readAllByQuery(
241+
specificQueryForClient, // Pass the potentially empty map
242+
userId: userIdForRepoCall,
243+
startAfterId: startAfterId,
244+
limit: limit,
245+
);
246+
break;
168247
case 'user_app_settings':
169248
final repo = context.read<HtDataRepository<UserAppSettings>>();
170-
// Note: While readAll/readAllByQuery is used here for consistency
171-
// with the generic endpoint, fetching the user's settings by ID
172-
// via the /data/[id] route is the semantically preferred method
173-
// for this single-instance, user-owned model.
174-
paginatedResponse = specificQuery.isNotEmpty
175-
? await repo.readAllByQuery(
176-
specificQuery,
177-
userId: userIdForRepoCall,
178-
startAfterId: startAfterId,
179-
limit: limit,
180-
)
181-
: await repo.readAll(
182-
userId: userIdForRepoCall,
183-
startAfterId: startAfterId,
184-
limit: limit,
185-
);
249+
paginatedResponse = await repo.readAllByQuery(
250+
specificQueryForClient,
251+
userId: userIdForRepoCall,
252+
startAfterId: startAfterId,
253+
limit: limit,
254+
);
255+
break;
186256
case 'user_content_preferences':
187257
final repo = context.read<HtDataRepository<UserContentPreferences>>();
188-
// Note: While readAll/readAllByQuery is used here for consistency
189-
// with the generic endpoint, fetching the user's preferences by ID
190-
// via the /data/[id] route is the semantically preferred method
191-
// for this single-instance, user-owned model.
192-
paginatedResponse = specificQuery.isNotEmpty
193-
? await repo.readAllByQuery(
194-
specificQuery,
195-
userId: userIdForRepoCall,
196-
startAfterId: startAfterId,
197-
limit: limit,
198-
)
199-
: await repo.readAll(
200-
userId: userIdForRepoCall,
201-
startAfterId: startAfterId,
202-
limit: limit,
203-
);
258+
paginatedResponse = await repo.readAllByQuery(
259+
specificQueryForClient,
260+
userId: userIdForRepoCall,
261+
startAfterId: startAfterId,
262+
limit: limit,
263+
);
264+
break;
204265
case 'app_config':
205266
final repo = context.read<HtDataRepository<AppConfig>>();
206-
// Note: While readAll/readAllByQuery is used here for consistency
207-
// with the generic endpoint, fetching the single AppConfig instance
208-
// by its fixed ID ('app_config') via the /data/[id] route is the
209-
// semantically preferred method for this global singleton model.
210-
paginatedResponse = specificQuery.isNotEmpty
211-
? await repo.readAllByQuery(
212-
specificQuery,
213-
userId: userIdForRepoCall, // userId should be null for AppConfig
214-
startAfterId: startAfterId,
215-
limit: limit,
216-
)
217-
: await repo.readAll(
218-
userId: userIdForRepoCall, // userId should be null for AppConfig
219-
startAfterId: startAfterId,
220-
limit: limit,
221-
);
267+
paginatedResponse = await repo.readAllByQuery(
268+
specificQueryForClient,
269+
userId: userIdForRepoCall,
270+
startAfterId: startAfterId,
271+
limit: limit,
272+
);
273+
break;
222274
default:
223-
// This case should be caught by middleware, but added for safety
224-
// Throw an exception to be caught by the errorHandler
225275
throw OperationFailedException(
226-
'Unsupported model type "$modelName" reached handler.',
276+
'Unsupported model type "$modelName" reached data retrieval switch.',
227277
);
228278
}
229279

230-
// --- Feed Enhancement ---
231-
// Only enhance if the primary model is a type that can be part of a mixed feed.
232-
// If not a model to enhance, just cast the original items to FeedItem
233-
// finalFeedItems = paginatedResponse.items.cast<FeedItem>();
234-
// The items are already dynamic, so direct assignment is fine.
235280
final finalFeedItems = paginatedResponse.items;
236-
237-
// Create metadata including the request ID and current timestamp
238281
final metadata = ResponseMetadata(
239282
requestId: requestId,
240283
timestamp: DateTime.now().toUtc(), // Use UTC for consistency

0 commit comments

Comments
 (0)