@@ -49,8 +49,55 @@ Future<Response> onRequest(RequestContext context) async {
49
49
}
50
50
51
51
// --- 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.
54
101
Future <Response > _handleGet (
55
102
RequestContext context,
56
103
String modelName,
@@ -59,182 +106,178 @@ Future<Response> _handleGet(
59
106
PermissionService permissionService,
60
107
String requestId,
61
108
) 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
66
109
final queryParams = context.request.uri.queryParameters;
67
110
final startAfterId = queryParams['startAfterId' ];
68
111
final limitParam = queryParams['limit' ];
69
112
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' );
74
113
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 ();
77
118
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
+ }
88
195
}
89
196
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
92
201
switch (modelName) {
93
202
case 'headline' :
94
203
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 ;
107
211
case 'category' :
108
212
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 ;
121
220
case 'source' :
122
221
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 ;
135
229
case 'country' :
136
230
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 ;
149
238
case 'user' :
150
239
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 ;
168
247
case 'user_app_settings' :
169
248
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 ;
186
256
case 'user_content_preferences' :
187
257
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 ;
204
265
case 'app_config' :
205
266
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 ;
222
274
default :
223
- // This case should be caught by middleware, but added for safety
224
- // Throw an exception to be caught by the errorHandler
225
275
throw OperationFailedException (
226
- 'Unsupported model type "$modelName " reached handler .' ,
276
+ 'Unsupported model type "$modelName " reached data retrieval switch .' ,
227
277
);
228
278
}
229
279
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.
235
280
final finalFeedItems = paginatedResponse.items;
236
-
237
- // Create metadata including the request ID and current timestamp
238
281
final metadata = ResponseMetadata (
239
282
requestId: requestId,
240
283
timestamp: DateTime .now ().toUtc (), // Use UTC for consistency
0 commit comments