11import os
2+ import hashlib
23import inspect
34import json
45import shutil
56import tempfile
67import typing
78from typing import Any , Callable
89
9- from flask import Blueprint , request , jsonify , abort
10+ from flask import Blueprint , Response , current_app , request , jsonify , abort
1011from werkzeug .datastructures import FileStorage
1112from werkzeug .utils import secure_filename
1213from flamapy .interfaces .python .flamapy_feature_model import FLAMAFeatureModel , Backend
13- from flamapy .core .exceptions import FlamaException
1414from flamapy .metamodels .configuration_metamodel .models import Configuration
1515
16+ from flamapy .interfaces .rest .extensions import (
17+ limiter ,
18+ result_cache ,
19+ default_operation_limit ,
20+ expensive_operation_limit ,
21+ )
22+ from flamapy .interfaces .rest .runner import ModelParseError , OperationTimeout , run_operation
23+
1624
1725operations_bp = Blueprint ('operations_bp' , __name__ , url_prefix = '/api/v1/operations' )
1826
27+ # Operations that enumerate or repeatedly invoke a solver; they get the stricter
28+ # rate-limit tier because a single call can monopolize a CPU for a long time.
29+ _EXPENSIVE_OPERATIONS = {
30+ 'configurations' ,
31+ 'configurations_number' ,
32+ 'configurations_with_n_features' ,
33+ 'conflict' ,
34+ 'diagnosis' ,
35+ 'feature_inclusion_probability' ,
36+ 'homogeneity' ,
37+ 'product_distribution' ,
38+ 'sampling' ,
39+ 'unique_features' ,
40+ 'variability' ,
41+ 'variant_features' ,
42+ }
43+
1944# Backward-compatible multipart field names for parameters that the API exposed
2045# before the dispatcher became generic. Any other parameter derives its field
2146# name from the parameter itself (file params drop the trailing "_path").
@@ -116,6 +141,26 @@ def _resolve_kwargs(operation: Any, work_dir: str) -> dict[str, Any]:
116141 return kwargs
117142
118143
144+ def _cache_key (
145+ operation_name : str , operation : Any , model_path : str , kwargs : dict [str , Any ]
146+ ) -> str :
147+ """Digest of everything that determines the result: the operation, the model
148+ contents and every argument (file arguments by content, not by temp path)."""
149+ file_params = {p .name for p in _operation_params (operation ) if _is_file_param (p )}
150+ digest = hashlib .sha256 ()
151+ digest .update (operation_name .encode ())
152+ with open (model_path , 'rb' ) as model_file :
153+ digest .update (model_file .read ())
154+ for name in sorted (kwargs ):
155+ digest .update (b'\x00 ' + name .encode () + b'\x00 ' )
156+ if name in file_params :
157+ with open (kwargs [name ], 'rb' ) as uploaded_file :
158+ digest .update (uploaded_file .read ())
159+ else :
160+ digest .update (repr (kwargs [name ]).encode ())
161+ return digest .hexdigest ()
162+
163+
119164def _api_call (operation_name : str ) -> Any :
120165 uploaded_model = request .files .get ('model' )
121166 if uploaded_model is None or not uploaded_model .filename :
@@ -126,19 +171,34 @@ def _api_call(operation_name: str) -> Any:
126171 work_dir = tempfile .mkdtemp (prefix = 'flamapy_' )
127172 try :
128173 model_path = _save_upload (uploaded_model , work_dir )
174+ operation = getattr (FLAMAFeatureModel , operation_name )
175+ kwargs = _resolve_kwargs (operation , work_dir )
176+
177+ key = _cache_key (operation_name , operation , model_path , kwargs )
178+ payload = result_cache .get (key )
179+ if payload is not None :
180+ response : Response = jsonify (payload )
181+ response .headers ['X-Cache' ] = 'HIT'
182+ return response
183+
129184 try :
130- fm = FLAMAFeatureModel (model_path )
131- except FlamaException :
185+ result = run_operation (
186+ model_path , operation_name , kwargs , current_app .config ['OPERATION_TIMEOUT' ]
187+ )
188+ except ModelParseError :
132189 abort (400 , "The uploaded model could not be parsed" )
133- operation = getattr (fm , operation_name )
134- kwargs = _resolve_kwargs (operation , work_dir )
135- result = operation (** kwargs )
190+ except OperationTimeout as exc :
191+ abort (504 , str (exc ))
136192 finally :
137193 shutil .rmtree (work_dir , ignore_errors = True )
138194
139195 if result is None :
140196 return jsonify (error = 'Not valid result' ), 404
141- return jsonify (json .loads (json .dumps (result , cls = CustomJSONEncoder )))
197+ payload = json .loads (json .dumps (result , cls = CustomJSONEncoder ))
198+ result_cache .set (key , payload )
199+ response = jsonify (payload )
200+ response .headers ['X-Cache' ] = 'MISS'
201+ return response
142202
143203
144204def extract_docstring_with_swagger_info (method : Any ) -> str :
@@ -189,9 +249,12 @@ def route_function() -> Any:
189249
190250
191251# Introspect FLAMAFeatureModel to expose every public method as a POST route,
192- # generating its Swagger spec from the method signature.
252+ # generating its Swagger spec from the method signature. Limits are passed as
253+ # callables so they read the app config of whichever app the blueprint joins.
193254for name , method in inspect .getmembers (FLAMAFeatureModel , predicate = inspect .isfunction ):
194255 if name .startswith ('_' ):
195256 continue
196257 docstring = extract_docstring_with_swagger_info (method )
197- operations_bp .route (f'/{ name } ' , methods = ['POST' ])(create_route (name , docstring ))
258+ limit = expensive_operation_limit if name in _EXPENSIVE_OPERATIONS else default_operation_limit
259+ route_view = limiter .limit (limit )(create_route (name , docstring ))
260+ operations_bp .route (f'/{ name } ' , methods = ['POST' ])(route_view )
0 commit comments