flamapy-rest 2.6.0.dev2__tar.gz → 2.6.0.dev6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ Metadata-Version: 2.4
2
+ Name: flamapy-rest
3
+ Version: 2.6.0.dev6
4
+ Summary: Flamapy feature model is a distribution of the flama framework containing all plugins required to analyze feature models. It also offers a richer API and a complete command line interface and documentation.
5
+ Author-email: Flamapy <flamapy@us.es>
6
+ License-Expression: GPL-3.0-or-later
7
+ Project-URL: Homepage, https://github.com/flamapy/flamapy-rest
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: Flask
11
+ Requires-Dist: gunicorn
12
+ Requires-Dist: flamapy~=2.6.0.dev5
13
+ Requires-Dist: flask-swagger-ui
14
+ Requires-Dist: flask-restplus
15
+ Requires-Dist: flask_cors
16
+ Requires-Dist: flasgger
17
+ Requires-Dist: Flask-Limiter
18
+ Requires-Dist: cachetools
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest; extra == "dev"
21
+ Requires-Dist: pytest-mock; extra == "dev"
22
+ Requires-Dist: ruff; extra == "dev"
23
+ Requires-Dist: mypy; extra == "dev"
24
+ Requires-Dist: coverage; extra == "dev"
25
+
26
+ <div align="center">
27
+
28
+ <a href="https://github.com/flamapy/flamapy-rest/actions/workflows/main.yml">![Python analysis](https://github.com/flamapy/flamapy-rest/actions/workflows/main.yml/badge.svg?branch=main)</a>
29
+ <a href="https://github.com/flamapy/flamapy-rest/actions/workflows/conventionalpr.yml">![Conventional Commits](https://github.com/flamapy/flamapy-rest/actions/workflows/conventionalpr.yml/badge.svg?branch=main)</a>
30
+ <a href="https://github.com/flamapy/flamapy-rest/actions/workflows/docker-image.yml">![Docker Image CI](https://github.com/flamapy/flamapy-rest/actions/workflows/docker-image.yml/badge.svg)</a>
31
+ <a href="https://pypi.org/project/flamapy-rest/">![PyPI](https://img.shields.io/pypi/v/flamapy-rest?label=pypi%20package)</a>
32
+ <a href="https://hub.docker.com/r/flamapy/flamapy-rest">![Docker Pulls](https://img.shields.io/docker/pulls/flamapy/flamapy-rest)</a>
33
+ <a href="https://pypi.org/project/flamapy-rest/">![PyPI - Downloads](https://img.shields.io/pypi/dm/flamapy-rest)</a>
34
+ </div>
35
+
36
+ <div id="top"></div>
37
+ <br />
38
+ <div align="center">
39
+
40
+ <h3 align="center">FLAMAPY REST API</h3>
41
+
42
+ <p align="center">
43
+ A new and easy way to use FLAMA
44
+ <br />
45
+ <a href="https://github.com/flamapy/flamapy-rest/issues">Report Bug</a>
46
+ ·
47
+ <a href="https://github.com/flamapy/flamapy-rest/issues">Request Feature</a>
48
+ </p>
49
+ </div>
50
+
51
+ <!-- ABOUT THE PROJECT -->
52
+ ## About The Project
53
+
54
+ `flamapy-rest` is a [Flask](https://flask.palletsprojects.com/) REST API that wraps the
55
+ [FLAMAPY](https://flamapy.github.io/) framework for **feature model analysis**. It dynamically
56
+ exposes FLAMAPY operations (dead features, false-optional features, configurations, satisfiability,
57
+ diagnosis, …) as HTTP endpoints, with self-generated [Swagger](https://swagger.io/) documentation.
58
+
59
+ Feature model analysis has a crucial role in software product line engineering, enabling us to
60
+ understand, design, and validate the complex relationships among features in a software product
61
+ line. These models can be complex and challenging to analyze due to their variability, making it
62
+ difficult to identify conflicts, dead features, and potential optimizations. This REST API makes
63
+ that analysis available to any tool or language that can speak HTTP.
64
+
65
+ Please note: this is a living document and we will continue to update and improve it as we release
66
+ new versions of the plugins and receive feedback from our users. If there's anything you don't
67
+ understand or if you have any suggestions for improvement, don't hesitate to
68
+ [open an issue](https://github.com/flamapy/flamapy-rest/issues). We're here to help!
69
+
70
+ ### Built With
71
+
72
+ * [Docker](https://www.docker.com/)
73
+ * [Flask](https://flask.palletsprojects.com/)
74
+ * [FLAMAPY](https://github.com/flamapy)
75
+ * [Flasgger](https://github.com/flasgger/flasgger)
76
+
77
+ <p align="right">(<a href="#top">back to top</a>)</p>
78
+
79
+ <!-- GETTING STARTED -->
80
+ ## Getting Started
81
+
82
+ There are three supported ways to run the API. **Using the published Docker image is the easiest** —
83
+ nothing to build, and it already bundles FLAMAPY and all of its analysis plugins.
84
+
85
+ ### Option 1 — Run the published Docker image (recommended)
86
+
87
+ The CI publishes a multi-arch image (`linux/amd64` and `linux/arm64`) to Docker Hub at
88
+ [`flamapy/flamapy-rest`](https://hub.docker.com/r/flamapy/flamapy-rest) on every version tag.
89
+
90
+ ```bash
91
+ # Pull and run the latest release
92
+ docker run --rm -p 8000:8000 flamapy/flamapy-rest
93
+ ```
94
+
95
+ Then open:
96
+
97
+ * API home: <http://localhost:8000/>
98
+ * Swagger UI / API docs: <http://localhost:8000/docs/>
99
+
100
+ To pin a specific version, use its tag (matching a release, e.g. `v2.6.0`):
101
+
102
+ ```bash
103
+ docker run --rm -p 8000:8000 flamapy/flamapy-rest:v2.6.0
104
+ ```
105
+
106
+ Run it detached and give the container a name so it's easy to stop later:
107
+
108
+ ```bash
109
+ docker run -d --name flamapy-rest -p 8000:8000 flamapy/flamapy-rest
110
+ docker logs -f flamapy-rest # follow the logs
111
+ docker stop flamapy-rest # stop it
112
+ docker rm flamapy-rest # remove it
113
+ ```
114
+
115
+ ### Option 2 — Build the Docker image from source
116
+
117
+ If you want to run your local changes, clone the repo and build the image yourself. A helper script
118
+ is provided that builds, runs, and (on exit) cleans up the container and image:
119
+
120
+ ```bash
121
+ # Linux / macOS
122
+ ./start-server.sh
123
+
124
+ # Windows
125
+ start-server.cmd
126
+ ```
127
+
128
+ Or do it by hand:
129
+
130
+ ```bash
131
+ docker build --tag flamapy-rest .
132
+ docker run --rm -p 8000:8000 flamapy-rest
133
+ ```
134
+
135
+ ### Option 3 — Install from PyPI and run locally
136
+
137
+ The API is also published on PyPI as [`flamapy-rest`](https://pypi.org/project/flamapy-rest/)
138
+ (requires **Python 3.11+**). Because the WSGI entrypoint lives in `app.py`, clone the repo to get it:
139
+
140
+ ```bash
141
+ git clone https://github.com/flamapy/flamapy-rest.git
142
+ cd flamapy-rest
143
+ pip install .
144
+
145
+ # Production server
146
+ gunicorn --bind 0.0.0.0:8000 app:app
147
+
148
+ # …or the Flask development server
149
+ python -m flask run --host=0.0.0.0
150
+ ```
151
+
152
+ <p align="right">(<a href="#top">back to top</a>)</p>
153
+
154
+ ## Usage
155
+
156
+ Every public operation of FLAMAPY's `FLAMAFeatureModel` facade is exposed as a `POST` endpoint under
157
+ `/api/v1/operations/<operation_name>`. Each call uploads a feature model file (e.g. UVL) as
158
+ multipart form data; some operations also accept an optional `feature` or `configuration` parameter.
159
+
160
+ ```bash
161
+ # Example: count the number of valid configurations of a model
162
+ curl -X POST http://localhost:8000/api/v1/operations/configurations_number \
163
+ -F "model=@resources/models/simple/valid_model.uvl"
164
+ ```
165
+
166
+ The full, interactive list of endpoints — with parameters and "try it out" support — is available
167
+ in the Swagger UI at <http://localhost:8000/docs/>.
168
+
169
+ <p align="right">(<a href="#top">back to top</a>)</p>
170
+
171
+ ## Abuse controls & configuration
172
+
173
+ The API ships with per-IP rate limiting, a per-operation time budget, upload size limits and a
174
+ result cache, all configurable through environment variables (e.g. `docker run -e FLAMAPY_...`):
175
+
176
+ | Env var | Default | Meaning |
177
+ |---|---|---|
178
+ | `FLAMAPY_MAX_CONTENT_LENGTH` | `16777216` | Max upload size in bytes; larger requests get **413** |
179
+ | `FLAMAPY_RATELIMIT_ENABLED` | `true` | Turn rate limiting on/off |
180
+ | `FLAMAPY_RATELIMIT_DEFAULT` | `60 per minute` | Per-IP limit for cheap operations |
181
+ | `FLAMAPY_RATELIMIT_EXPENSIVE` | `10 per minute` | Per-IP limit for enumeration/solver-heavy operations (`configurations`, `configurations_number`, `sampling`, …); exceeding a limit returns **429** |
182
+ | `FLAMAPY_RATELIMIT_STORAGE_URI` | `memory://` | Limiter storage; use `redis://host:6379` when running several gunicorn workers so they share counters |
183
+ | `FLAMAPY_OPERATION_TIMEOUT` | `60` | Seconds an operation may run before it is killed and **504** is returned; `0` disables |
184
+ | `FLAMAPY_CACHE_TTL` | `3600` | Seconds a result stays cached (same model + operation + arguments); `0` disables. Responses carry an `X-Cache: HIT|MISS` header |
185
+ | `FLAMAPY_CACHE_MAXSIZE` | `128` | Max cached results per worker |
186
+ | `FLAMAPY_TRUST_PROXY` | `false` | Set to `true` behind a reverse proxy so rate limits see the real client IP (`X-Forwarded-For`) |
187
+ | `WEB_CONCURRENCY` | `2` | gunicorn worker count (Docker image) |
188
+ | `GUNICORN_TIMEOUT` | `120` | gunicorn hard worker timeout; keep it above `FLAMAPY_OPERATION_TIMEOUT` |
189
+
190
+ Every request is logged to stdout (client IP, path, status, duration, upload size, cache result),
191
+ so `docker logs` is enough to spot heavy users.
192
+
193
+ <p align="right">(<a href="#top">back to top</a>)</p>
194
+
195
+ ## API Documentation
196
+
197
+ All documentation is registered with Swagger UI and OAS 3.0, served at
198
+ [`/docs/`](http://localhost:8000/docs/). It is generated dynamically by
199
+ [Flasgger](https://github.com/flasgger/flasgger) from the route docstrings and method signatures, so
200
+ **don't forget to document your code in the route files** — new operations show up automatically.
201
+
202
+ <p align="right">(<a href="#top">back to top</a>)</p>
203
+
204
+ ## Contributing
205
+
206
+ Contributions are welcome. This repo enforces
207
+ [Conventional Commits](https://www.conventionalcommits.org/) on pull request titles, and runs Ruff,
208
+ mypy, and pytest in CI.
209
+
210
+ ```bash
211
+ pip install .[dev] # install dev tooling
212
+
213
+ ruff check . # lint
214
+ mypy -p flamapy # static type checking
215
+ pytest # tests
216
+ ```
217
+
218
+ <p align="right">(<a href="#top">back to top</a>)</p>
@@ -0,0 +1,193 @@
1
+ <div align="center">
2
+
3
+ <a href="https://github.com/flamapy/flamapy-rest/actions/workflows/main.yml">![Python analysis](https://github.com/flamapy/flamapy-rest/actions/workflows/main.yml/badge.svg?branch=main)</a>
4
+ <a href="https://github.com/flamapy/flamapy-rest/actions/workflows/conventionalpr.yml">![Conventional Commits](https://github.com/flamapy/flamapy-rest/actions/workflows/conventionalpr.yml/badge.svg?branch=main)</a>
5
+ <a href="https://github.com/flamapy/flamapy-rest/actions/workflows/docker-image.yml">![Docker Image CI](https://github.com/flamapy/flamapy-rest/actions/workflows/docker-image.yml/badge.svg)</a>
6
+ <a href="https://pypi.org/project/flamapy-rest/">![PyPI](https://img.shields.io/pypi/v/flamapy-rest?label=pypi%20package)</a>
7
+ <a href="https://hub.docker.com/r/flamapy/flamapy-rest">![Docker Pulls](https://img.shields.io/docker/pulls/flamapy/flamapy-rest)</a>
8
+ <a href="https://pypi.org/project/flamapy-rest/">![PyPI - Downloads](https://img.shields.io/pypi/dm/flamapy-rest)</a>
9
+ </div>
10
+
11
+ <div id="top"></div>
12
+ <br />
13
+ <div align="center">
14
+
15
+ <h3 align="center">FLAMAPY REST API</h3>
16
+
17
+ <p align="center">
18
+ A new and easy way to use FLAMA
19
+ <br />
20
+ <a href="https://github.com/flamapy/flamapy-rest/issues">Report Bug</a>
21
+ ·
22
+ <a href="https://github.com/flamapy/flamapy-rest/issues">Request Feature</a>
23
+ </p>
24
+ </div>
25
+
26
+ <!-- ABOUT THE PROJECT -->
27
+ ## About The Project
28
+
29
+ `flamapy-rest` is a [Flask](https://flask.palletsprojects.com/) REST API that wraps the
30
+ [FLAMAPY](https://flamapy.github.io/) framework for **feature model analysis**. It dynamically
31
+ exposes FLAMAPY operations (dead features, false-optional features, configurations, satisfiability,
32
+ diagnosis, …) as HTTP endpoints, with self-generated [Swagger](https://swagger.io/) documentation.
33
+
34
+ Feature model analysis has a crucial role in software product line engineering, enabling us to
35
+ understand, design, and validate the complex relationships among features in a software product
36
+ line. These models can be complex and challenging to analyze due to their variability, making it
37
+ difficult to identify conflicts, dead features, and potential optimizations. This REST API makes
38
+ that analysis available to any tool or language that can speak HTTP.
39
+
40
+ Please note: this is a living document and we will continue to update and improve it as we release
41
+ new versions of the plugins and receive feedback from our users. If there's anything you don't
42
+ understand or if you have any suggestions for improvement, don't hesitate to
43
+ [open an issue](https://github.com/flamapy/flamapy-rest/issues). We're here to help!
44
+
45
+ ### Built With
46
+
47
+ * [Docker](https://www.docker.com/)
48
+ * [Flask](https://flask.palletsprojects.com/)
49
+ * [FLAMAPY](https://github.com/flamapy)
50
+ * [Flasgger](https://github.com/flasgger/flasgger)
51
+
52
+ <p align="right">(<a href="#top">back to top</a>)</p>
53
+
54
+ <!-- GETTING STARTED -->
55
+ ## Getting Started
56
+
57
+ There are three supported ways to run the API. **Using the published Docker image is the easiest** —
58
+ nothing to build, and it already bundles FLAMAPY and all of its analysis plugins.
59
+
60
+ ### Option 1 — Run the published Docker image (recommended)
61
+
62
+ The CI publishes a multi-arch image (`linux/amd64` and `linux/arm64`) to Docker Hub at
63
+ [`flamapy/flamapy-rest`](https://hub.docker.com/r/flamapy/flamapy-rest) on every version tag.
64
+
65
+ ```bash
66
+ # Pull and run the latest release
67
+ docker run --rm -p 8000:8000 flamapy/flamapy-rest
68
+ ```
69
+
70
+ Then open:
71
+
72
+ * API home: <http://localhost:8000/>
73
+ * Swagger UI / API docs: <http://localhost:8000/docs/>
74
+
75
+ To pin a specific version, use its tag (matching a release, e.g. `v2.6.0`):
76
+
77
+ ```bash
78
+ docker run --rm -p 8000:8000 flamapy/flamapy-rest:v2.6.0
79
+ ```
80
+
81
+ Run it detached and give the container a name so it's easy to stop later:
82
+
83
+ ```bash
84
+ docker run -d --name flamapy-rest -p 8000:8000 flamapy/flamapy-rest
85
+ docker logs -f flamapy-rest # follow the logs
86
+ docker stop flamapy-rest # stop it
87
+ docker rm flamapy-rest # remove it
88
+ ```
89
+
90
+ ### Option 2 — Build the Docker image from source
91
+
92
+ If you want to run your local changes, clone the repo and build the image yourself. A helper script
93
+ is provided that builds, runs, and (on exit) cleans up the container and image:
94
+
95
+ ```bash
96
+ # Linux / macOS
97
+ ./start-server.sh
98
+
99
+ # Windows
100
+ start-server.cmd
101
+ ```
102
+
103
+ Or do it by hand:
104
+
105
+ ```bash
106
+ docker build --tag flamapy-rest .
107
+ docker run --rm -p 8000:8000 flamapy-rest
108
+ ```
109
+
110
+ ### Option 3 — Install from PyPI and run locally
111
+
112
+ The API is also published on PyPI as [`flamapy-rest`](https://pypi.org/project/flamapy-rest/)
113
+ (requires **Python 3.11+**). Because the WSGI entrypoint lives in `app.py`, clone the repo to get it:
114
+
115
+ ```bash
116
+ git clone https://github.com/flamapy/flamapy-rest.git
117
+ cd flamapy-rest
118
+ pip install .
119
+
120
+ # Production server
121
+ gunicorn --bind 0.0.0.0:8000 app:app
122
+
123
+ # …or the Flask development server
124
+ python -m flask run --host=0.0.0.0
125
+ ```
126
+
127
+ <p align="right">(<a href="#top">back to top</a>)</p>
128
+
129
+ ## Usage
130
+
131
+ Every public operation of FLAMAPY's `FLAMAFeatureModel` facade is exposed as a `POST` endpoint under
132
+ `/api/v1/operations/<operation_name>`. Each call uploads a feature model file (e.g. UVL) as
133
+ multipart form data; some operations also accept an optional `feature` or `configuration` parameter.
134
+
135
+ ```bash
136
+ # Example: count the number of valid configurations of a model
137
+ curl -X POST http://localhost:8000/api/v1/operations/configurations_number \
138
+ -F "model=@resources/models/simple/valid_model.uvl"
139
+ ```
140
+
141
+ The full, interactive list of endpoints — with parameters and "try it out" support — is available
142
+ in the Swagger UI at <http://localhost:8000/docs/>.
143
+
144
+ <p align="right">(<a href="#top">back to top</a>)</p>
145
+
146
+ ## Abuse controls & configuration
147
+
148
+ The API ships with per-IP rate limiting, a per-operation time budget, upload size limits and a
149
+ result cache, all configurable through environment variables (e.g. `docker run -e FLAMAPY_...`):
150
+
151
+ | Env var | Default | Meaning |
152
+ |---|---|---|
153
+ | `FLAMAPY_MAX_CONTENT_LENGTH` | `16777216` | Max upload size in bytes; larger requests get **413** |
154
+ | `FLAMAPY_RATELIMIT_ENABLED` | `true` | Turn rate limiting on/off |
155
+ | `FLAMAPY_RATELIMIT_DEFAULT` | `60 per minute` | Per-IP limit for cheap operations |
156
+ | `FLAMAPY_RATELIMIT_EXPENSIVE` | `10 per minute` | Per-IP limit for enumeration/solver-heavy operations (`configurations`, `configurations_number`, `sampling`, …); exceeding a limit returns **429** |
157
+ | `FLAMAPY_RATELIMIT_STORAGE_URI` | `memory://` | Limiter storage; use `redis://host:6379` when running several gunicorn workers so they share counters |
158
+ | `FLAMAPY_OPERATION_TIMEOUT` | `60` | Seconds an operation may run before it is killed and **504** is returned; `0` disables |
159
+ | `FLAMAPY_CACHE_TTL` | `3600` | Seconds a result stays cached (same model + operation + arguments); `0` disables. Responses carry an `X-Cache: HIT|MISS` header |
160
+ | `FLAMAPY_CACHE_MAXSIZE` | `128` | Max cached results per worker |
161
+ | `FLAMAPY_TRUST_PROXY` | `false` | Set to `true` behind a reverse proxy so rate limits see the real client IP (`X-Forwarded-For`) |
162
+ | `WEB_CONCURRENCY` | `2` | gunicorn worker count (Docker image) |
163
+ | `GUNICORN_TIMEOUT` | `120` | gunicorn hard worker timeout; keep it above `FLAMAPY_OPERATION_TIMEOUT` |
164
+
165
+ Every request is logged to stdout (client IP, path, status, duration, upload size, cache result),
166
+ so `docker logs` is enough to spot heavy users.
167
+
168
+ <p align="right">(<a href="#top">back to top</a>)</p>
169
+
170
+ ## API Documentation
171
+
172
+ All documentation is registered with Swagger UI and OAS 3.0, served at
173
+ [`/docs/`](http://localhost:8000/docs/). It is generated dynamically by
174
+ [Flasgger](https://github.com/flasgger/flasgger) from the route docstrings and method signatures, so
175
+ **don't forget to document your code in the route files** — new operations show up automatically.
176
+
177
+ <p align="right">(<a href="#top">back to top</a>)</p>
178
+
179
+ ## Contributing
180
+
181
+ Contributions are welcome. This repo enforces
182
+ [Conventional Commits](https://www.conventionalcommits.org/) on pull request titles, and runs Ruff,
183
+ mypy, and pytest in CI.
184
+
185
+ ```bash
186
+ pip install .[dev] # install dev tooling
187
+
188
+ ruff check . # lint
189
+ mypy -p flamapy # static type checking
190
+ pytest # tests
191
+ ```
192
+
193
+ <p align="right">(<a href="#top">back to top</a>)</p>
@@ -0,0 +1,36 @@
1
+ import os
2
+ from typing import Any
3
+
4
+
5
+ def _env_bool(name: str, default: bool) -> bool:
6
+ raw = os.environ.get(name)
7
+ if raw is None:
8
+ return default
9
+ return raw.strip().lower() in ('1', 'true', 'yes', 'on')
10
+
11
+
12
+ def _env_int(name: str, default: int) -> int:
13
+ raw = os.environ.get(name)
14
+ if raw is None or raw.strip() == '':
15
+ return default
16
+ return int(raw)
17
+
18
+
19
+ def load_config() -> dict[str, Any]:
20
+ """Abuse-control settings, every one overridable through an env var so the
21
+ Docker image stays a single self-contained container."""
22
+ return {
23
+ 'MAX_CONTENT_LENGTH': _env_int('FLAMAPY_MAX_CONTENT_LENGTH', 16 * 1024 * 1024),
24
+ 'RATELIMIT_ENABLED': _env_bool('FLAMAPY_RATELIMIT_ENABLED', True),
25
+ 'RATELIMIT_STORAGE_URI': os.environ.get('FLAMAPY_RATELIMIT_STORAGE_URI', 'memory://'),
26
+ 'RATELIMIT_DEFAULT_OPERATION': os.environ.get(
27
+ 'FLAMAPY_RATELIMIT_DEFAULT', '60 per minute'
28
+ ),
29
+ 'RATELIMIT_EXPENSIVE_OPERATION': os.environ.get(
30
+ 'FLAMAPY_RATELIMIT_EXPENSIVE', '10 per minute'
31
+ ),
32
+ 'OPERATION_TIMEOUT': _env_int('FLAMAPY_OPERATION_TIMEOUT', 60),
33
+ 'CACHE_TTL': _env_int('FLAMAPY_CACHE_TTL', 3600),
34
+ 'CACHE_MAXSIZE': _env_int('FLAMAPY_CACHE_MAXSIZE', 128),
35
+ 'TRUST_PROXY': _env_bool('FLAMAPY_TRUST_PROXY', False),
36
+ }
@@ -0,0 +1,52 @@
1
+ import threading
2
+ from typing import Any, Optional
3
+
4
+ from cachetools import TTLCache
5
+ from flask import Flask, current_app
6
+ from flask_limiter import Limiter
7
+ from flask_limiter.util import get_remote_address
8
+
9
+
10
+ def default_operation_limit() -> str:
11
+ return current_app.config['RATELIMIT_DEFAULT_OPERATION']
12
+
13
+
14
+ def expensive_operation_limit() -> str:
15
+ return current_app.config['RATELIMIT_EXPENSIVE_OPERATION']
16
+
17
+
18
+ # Storage URI and enabled flag are picked up from app.config (RATELIMIT_*)
19
+ # during init_app; per-route limits are attached in operations_routes.
20
+ limiter = Limiter(key_func=get_remote_address)
21
+
22
+
23
+ class ResultCache:
24
+ """Per-process TTL cache for operation results. The cache object is created
25
+ on init_app so its size/TTL follow the app configuration; a TTL of 0
26
+ disables caching entirely."""
27
+
28
+ def __init__(self) -> None:
29
+ self._cache: Optional[TTLCache[str, Any]] = None
30
+ self._lock = threading.Lock()
31
+
32
+ def init_app(self, app: Flask) -> None:
33
+ ttl = app.config['CACHE_TTL']
34
+ if ttl > 0:
35
+ self._cache = TTLCache(maxsize=app.config['CACHE_MAXSIZE'], ttl=ttl)
36
+ else:
37
+ self._cache = None
38
+
39
+ def get(self, key: str) -> Any:
40
+ if self._cache is None:
41
+ return None
42
+ with self._lock:
43
+ return self._cache.get(key)
44
+
45
+ def set(self, key: str, value: Any) -> None:
46
+ if self._cache is None:
47
+ return
48
+ with self._lock:
49
+ self._cache[key] = value
50
+
51
+
52
+ result_cache = ResultCache()
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import hashlib
2
3
  import inspect
3
4
  import json
4
5
  import shutil
@@ -6,16 +7,40 @@ import tempfile
6
7
  import typing
7
8
  from typing import Any, Callable
8
9
 
9
- from flask import Blueprint, request, jsonify, abort
10
+ from flask import Blueprint, Response, current_app, request, jsonify, abort
10
11
  from werkzeug.datastructures import FileStorage
11
12
  from werkzeug.utils import secure_filename
12
13
  from flamapy.interfaces.python.flamapy_feature_model import FLAMAFeatureModel, Backend
13
- from flamapy.core.exceptions import FlamaException
14
14
  from flamapy.metamodels.configuration_metamodel.models import Configuration
15
15
 
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
+
16
24
 
17
25
  operations_bp = Blueprint('operations_bp', __name__, url_prefix='/api/v1/operations')
18
26
 
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
+
19
44
  # Backward-compatible multipart field names for parameters that the API exposed
20
45
  # before the dispatcher became generic. Any other parameter derives its field
21
46
  # 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]:
116
141
  return kwargs
117
142
 
118
143
 
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
+
119
164
  def _api_call(operation_name: str) -> Any:
120
165
  uploaded_model = request.files.get('model')
121
166
  if uploaded_model is None or not uploaded_model.filename:
@@ -126,19 +171,34 @@ def _api_call(operation_name: str) -> Any:
126
171
  work_dir = tempfile.mkdtemp(prefix='flamapy_')
127
172
  try:
128
173
  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
+
129
184
  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:
132
189
  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))
136
192
  finally:
137
193
  shutil.rmtree(work_dir, ignore_errors=True)
138
194
 
139
195
  if result is None:
140
196
  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
142
202
 
143
203
 
144
204
  def extract_docstring_with_swagger_info(method: Any) -> str:
@@ -189,9 +249,12 @@ def create_route(operation_name: str, docstring: str) -> Callable[[], Any]:
189
249
 
190
250
 
191
251
  # 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.
193
254
  for name, method in inspect.getmembers(FLAMAFeatureModel, predicate=inspect.isfunction):
194
255
  if name.startswith('_'):
195
256
  continue
196
257
  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)