@@ -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.
54101Future <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