From 97dd9aab58e0940e792a8ea77c45aa63a5dafa3a Mon Sep 17 00:00:00 2001 From: Nicola Date: Mon, 3 Mar 2025 19:50:38 -0500 Subject: [PATCH] api: implement preflight request * closes #442 --- caimira/docs/mkdocs/docs/code/rest_api.md | 47 ++++++++++++------- .../src/caimira/api/routes/base_handler.py | 33 +++++++++++-- caimira/tests/apps/test_api_app.py | 41 ++++++++++++++++ 3 files changed, 98 insertions(+), 23 deletions(-) create mode 100644 caimira/tests/apps/test_api_app.py diff --git a/caimira/docs/mkdocs/docs/code/rest_api.md b/caimira/docs/mkdocs/docs/code/rest_api.md index 91c53174..ba2a8e24 100644 --- a/caimira/docs/mkdocs/docs/code/rest_api.md +++ b/caimira/docs/mkdocs/docs/code/rest_api.md @@ -18,7 +18,7 @@ The `app-config` directory at the root of the project is specific to the CERN en * `src/caimira/api`: Contains the REST API implementation, defining the endpoints, request handling, and input/output formats. * `/app.py` : Entry point for the CAiMIRA backend, powered by the [Tornado](https://www.tornadoweb.org/en/stable/) framework. It sets up the server, defines the routes for handling reports, and starts the Tornado I/O loop. - * `/controller`: Contains the core logic for handling incoming API requests. It interprets user input, interact with models, and send responses back to the client. + * `/controller`: Contains the core logic for handling incoming API requests. It interprets user input, interact with models, and send responses back to the client. * `/routes`: Defines the API endpoints and associate them with the corresponding controller functions. * `src/caimira/calculator`: contains the core models used in CAiMIRA for processing inputs and generating outputs. * `/models`: Contains the classes reponsible to define the whole object oriented hierarchical model. @@ -51,17 +51,28 @@ To run the API, follow these steps from the root directory of the project: cd caimira pip install -e . - + - From [PyPI](https://pypi.org/project/caimira/): pip install caimira - + 2. Run the backend: python -m caimira.api.app The web server will be accessible at [http://localhost:8081/](http://localhost:8081/). - + +!!! warning "CORS Configuration" + When running a client web application on a different domain than the REST API server, + do not forget to set the environment variable `CAIMIRA_ALLOWED_ORIGINS`: + + `export CAIMIRA_ALLOWED_ORIGINS="https://myclientapp.org"` + + If you need to allow multiple domains, input a comma-separated list of domains: + + `export CAIMIRA_ALLOWED_ORIGINS="https://myclientapp.org,https://myclientapp2.org"` + + ### API Endpoints Currently, the REST API contains two routing categories that provide the generation of results for the main CAiMIRA outputs: @@ -75,7 +86,7 @@ Currently, the REST API contains two routing categories that provide the generat #### Virus Results ??? Abstract "POST **/virus/report** (virus report data generation):" - + * **Description**: Core endpoint that allows users to submit data for the virus report generation. Data is processed by the CAiMIRA engine, and the results are returned in the response. * **Input**: The body of the request must include the necessary input data in JSON format. Examples of the required input can be found [here](https://gitlab.cern.ch/caimira/caimira/-/blob/master/caimira/src/caimira/calculator/validators/defaults.py?ref_type=heads). * **Response**: On success (status code `200`), the response will contain the following structure: @@ -87,10 +98,10 @@ Currently, the REST API contains two routing categories that provide the generat ... } } - + * **Error Handling**: In case of errors, the API will return appropriate error messages and HTTP status codes, such as `400` for bad requests, `404` for not found, or `500` for internal server errors. - ??? note "Example body" + ??? note "Example body" { "activity_type": "office", "calculator_version": "N/A", @@ -128,7 +139,7 @@ Currently, the REST API contains two routing categories that provide the generat For the full list of accepted inputs and respective values please refer to CAiMIRA's official defaults in GitLab repository [here](https://gitlab.cern.ch/caimira/caimira/-/blob/master/caimira/src/caimira/calculator/validators/co2/co2_validator.py?ref_type=heads#L29). ??? note "Example cURL (with the above body)" - + curl -X POST "http://localhost:8081/virus/report" \ -H "Content-Type: application/json" \ -d '{ @@ -170,12 +181,12 @@ Currently, the REST API contains two routing categories that provide the generat #### CO₂ Results ??? Abstract "POST **/co2/transition_times** (suggested transition times)" - + * **Description**: Endpoint that allows users to retrieve the suggested times based on the CO₂ input data (occupancy and ventilation transition times). Data is processed by the CAiMIRA engine, and the results are returned in the response. ??? note "Example body" - { + { "CO2_data": "{\"times\":[8.000,8.033,8.067,8.100,8.133,8.167,8.200,8.233,8.267,8.300,8.333,8.367,8.400,8.433,8.467,8.500,8.533,8.567,8.600,8.633,8.667,8.700,8.733,8.767,8.800,8.833,8.867,8.900,8.933,8.967,9.000,9.033,9.067,9.100,9.133,9.167,9.200,9.233,9.267,9.300,9.333,9.367,9.400,9.433,9.467,9.500,9.533,9.567,9.600,9.633,9.667,9.700,9.733,9.767,9.800,9.833,9.867,9.900,9.933,9.967,10.000,10.033,10.067,10.100,10.133,10.167,10.200,10.233,10.267,10.300,10.333,10.367,10.400,10.433,10.467,10.500,10.533,10.567,10.600,10.633,10.667,10.700,10.733,10.767,10.800,10.833,10.867,10.900,10.933,10.967,11.000,11.033,11.067,11.100,11.133,11.167,11.200,11.233,11.267,11.300,11.333,11.367,11.400,11.433,11.467,11.500,11.533,11.567,11.600,11.633,11.667,11.700,11.733,11.767,11.800,11.833,11.867,11.900,11.933,11.967,12.000,12.033,12.067,12.100,12.133,12.167,12.200,12.233,12.267,12.300,12.333,12.367,12.400,12.433,12.467,12.500,12.533,12.567,12.600,12.633,12.667,12.700,12.733,12.767,12.800,12.833,12.867,12.900,12.933,12.967,13.001],\"CO2\":[445.189,443.284,440.908,443.431,442.366,444.094,445.152,445.656,447.968,447.998,443.950,442.547,439.313,438.225,441.433,441.190,443.804,445.173,446.494,445.278,452.073,458.844,470.828,478.147,488.338,502.126,522.057,545.519,579.881,616.245,641.154,676.288,701.938,720.464,746.933,765.830,779.098,794.173,810.624,825.967,838.340,854.355,876.382,886.208,898.408,921.718,942.848,953.812,978.956,990.321,1002.931,1017.361,1029.379,1041.028,1051.883,1067.220,1073.530,1079.738,1093.733,1104.814,1125.798,1141.115,1151.046,1160.053,1176.367,1193.665,1180.104,1015.334,864.746,802.681,774.455,728.268,697.326,676.063,657.555,640.564,606.534,595.925,577.753,553.605,530.213,524.968,523.153,521.534,512.944,505.297,502.056,502.463,505.248,507.477,509.171,511.313,513.780,520.393,529.137,532.798,530.111,523.964,521.574,519.052,510.294,509.982,514.349,518.396,524.603,521.003,519.448,523.313,527.460,528.326,526.355,527.008,529.968,534.019,535.616,533.514,530.552,522.348,524.243,532.021,539.127,538.836,526.186,517.509,507.993,493.703,485.632,479.527,471.584,472.226,468.206,463.099,461.038,458.980,456.354,458.615,459.162,462.963,465.558,468.448,475.207,480.323,488.962,527.992,579.613,606.594,611.218,617.023,635.927,651.079,676.647]}", "total_people":"2", "exposed_start":"08:30", @@ -190,10 +201,10 @@ Currently, the REST API contains two routing categories that provide the generat For the full list of accepted inputs and respective values please refer to CAiMIRA's official defaults in GitLab repository [here](https://gitlab.cern.ch/caimira/caimira/-/blob/master/caimira/src/caimira/calculator/validators/defaults.py?ref_type=heads). ??? "**Example cURL** (with the above body)" - + curl -X POST "http://localhost:8081/co2/transition_times" \ -H "Content-Type: application/json" \ - -d '{ + -d '{ "CO2_data": "{\"times\":[8.000,8.033,8.067,8.100,8.133,8.167,8.200,8.233,8.267,8.300,8.333,8.367,8.400,8.433,8.467,8.500,8.533,8.567,8.600,8.633,8.667,8.700,8.733,8.767,8.800,8.833,8.867,8.900,8.933,8.967,9.000,9.033,9.067,9.100,9.133,9.167,9.200,9.233,9.267,9.300,9.333,9.367,9.400,9.433,9.467,9.500,9.533,9.567,9.600,9.633,9.667,9.700,9.733,9.767,9.800,9.833,9.867,9.900,9.933,9.967,10.000,10.033,10.067,10.100,10.133,10.167,10.200,10.233,10.267,10.300,10.333,10.367,10.400,10.433,10.467,10.500,10.533,10.567,10.600,10.633,10.667,10.700,10.733,10.767,10.800,10.833,10.867,10.900,10.933,10.967,11.000,11.033,11.067,11.100,11.133,11.167,11.200,11.233,11.267,11.300,11.333,11.367,11.400,11.433,11.467,11.500,11.533,11.567,11.600,11.633,11.667,11.700,11.733,11.767,11.800,11.833,11.867,11.900,11.933,11.967,12.000,12.033,12.067,12.100,12.133,12.167,12.200,12.233,12.267,12.300,12.333,12.367,12.400,12.433,12.467,12.500,12.533,12.567,12.600,12.633,12.667,12.700,12.733,12.767,12.800,12.833,12.867,12.900,12.933,12.967,13.001],\"CO2\":[445.189,443.284,440.908,443.431,442.366,444.094,445.152,445.656,447.968,447.998,443.950,442.547,439.313,438.225,441.433,441.190,443.804,445.173,446.494,445.278,452.073,458.844,470.828,478.147,488.338,502.126,522.057,545.519,579.881,616.245,641.154,676.288,701.938,720.464,746.933,765.830,779.098,794.173,810.624,825.967,838.340,854.355,876.382,886.208,898.408,921.718,942.848,953.812,978.956,990.321,1002.931,1017.361,1029.379,1041.028,1051.883,1067.220,1073.530,1079.738,1093.733,1104.814,1125.798,1141.115,1151.046,1160.053,1176.367,1193.665,1180.104,1015.334,864.746,802.681,774.455,728.268,697.326,676.063,657.555,640.564,606.534,595.925,577.753,553.605,530.213,524.968,523.153,521.534,512.944,505.297,502.056,502.463,505.248,507.477,509.171,511.313,513.780,520.393,529.137,532.798,530.111,523.964,521.574,519.052,510.294,509.982,514.349,518.396,524.603,521.003,519.448,523.313,527.460,528.326,526.355,527.008,529.968,534.019,535.616,533.514,530.552,522.348,524.243,532.021,539.127,538.836,526.186,517.509,507.993,493.703,485.632,479.527,471.584,472.226,468.206,463.099,461.038,458.980,456.354,458.615,459.162,462.963,465.558,468.448,475.207,480.323,488.962,527.992,579.613,606.594,611.218,617.023,635.927,651.079,676.647]}", "total_people":"2", "exposed_start":"08:30", @@ -231,13 +242,13 @@ Currently, the REST API contains two routing categories that provide the generat } ??? Abstract "POST **/co2/report** (CO₂ report data generation)" - + * **Description**: Core endpoint that allows users to submit data for the CO₂ report generation. Data is processed by the CAiMIRA engine, and the results are returned in the response. * The input, response and error handling topics are similar to the previously described `virus/report` section. ??? note "Example body" - { + { "CO2_data": "{\"times\":[8.000,8.033,8.067,8.100,8.133,8.167,8.200,8.233,8.267,8.300,8.333,8.367,8.400,8.433,8.467,8.500,8.533,8.567,8.600,8.633,8.667,8.700,8.733,8.767,8.800,8.833,8.867,8.900,8.933,8.967,9.000,9.033,9.067,9.100,9.133,9.167,9.200,9.233,9.267,9.300,9.333,9.367,9.400,9.433,9.467,9.500,9.533,9.567,9.600,9.633,9.667,9.700,9.733,9.767,9.800,9.833,9.867,9.900,9.933,9.967,10.000,10.033,10.067,10.100,10.133,10.167,10.200,10.233,10.267,10.300,10.333,10.367,10.400,10.433,10.467,10.500,10.533,10.567,10.600,10.633,10.667,10.700,10.733,10.767,10.800,10.833,10.867,10.900,10.933,10.967,11.000,11.033,11.067,11.100,11.133,11.167,11.200,11.233,11.267,11.300,11.333,11.367,11.400,11.433,11.467,11.500,11.533,11.567,11.600,11.633,11.667,11.700,11.733,11.767,11.800,11.833,11.867,11.900,11.933,11.967,12.000,12.033,12.067,12.100,12.133,12.167,12.200,12.233,12.267,12.300,12.333,12.367,12.400,12.433,12.467,12.500,12.533,12.567,12.600,12.633,12.667,12.700,12.733,12.767,12.800,12.833,12.867,12.900,12.933,12.967,13.001],\"CO2\":[445.189,443.284,440.908,443.431,442.366,444.094,445.152,445.656,447.968,447.998,443.950,442.547,439.313,438.225,441.433,441.190,443.804,445.173,446.494,445.278,452.073,458.844,470.828,478.147,488.338,502.126,522.057,545.519,579.881,616.245,641.154,676.288,701.938,720.464,746.933,765.830,779.098,794.173,810.624,825.967,838.340,854.355,876.382,886.208,898.408,921.718,942.848,953.812,978.956,990.321,1002.931,1017.361,1029.379,1041.028,1051.883,1067.220,1073.530,1079.738,1093.733,1104.814,1125.798,1141.115,1151.046,1160.053,1176.367,1193.665,1180.104,1015.334,864.746,802.681,774.455,728.268,697.326,676.063,657.555,640.564,606.534,595.925,577.753,553.605,530.213,524.968,523.153,521.534,512.944,505.297,502.056,502.463,505.248,507.477,509.171,511.313,513.780,520.393,529.137,532.798,530.111,523.964,521.574,519.052,510.294,509.982,514.349,518.396,524.603,521.003,519.448,523.313,527.460,528.326,526.355,527.008,529.968,534.019,535.616,533.514,530.552,522.348,524.243,532.021,539.127,538.836,526.186,517.509,507.993,493.703,485.632,479.527,471.584,472.226,468.206,463.099,461.038,458.980,456.354,458.615,459.162,462.963,465.558,468.448,475.207,480.323,488.962,527.992,579.613,606.594,611.218,617.023,635.927,651.079,676.647]}", "total_people":"2", "exposed_start":"08:30", @@ -256,10 +267,10 @@ Currently, the REST API contains two routing categories that provide the generat For the full list of accepted inputs and respective values please refer to CAiMIRA's official defaults in GitLab repository [here](https://gitlab.cern.ch/caimira/caimira/-/blob/master/caimira/src/caimira/calculator/validators/defaults.py?ref_type=heads). ??? "**Example cURL** (with the above body)" - + curl -X POST "http://localhost:8081/co2/report" \ -H "Content-Type: application/json" \ - -d '{ + -d '{ "CO2_data": "{\"times\":[8.000,8.033,8.067,8.100,8.133,8.167,8.200,8.233,8.267,8.300,8.333,8.367,8.400,8.433,8.467,8.500,8.533,8.567,8.600,8.633,8.667,8.700,8.733,8.767,8.800,8.833,8.867,8.900,8.933,8.967,9.000,9.033,9.067,9.100,9.133,9.167,9.200,9.233,9.267,9.300,9.333,9.367,9.400,9.433,9.467,9.500,9.533,9.567,9.600,9.633,9.667,9.700,9.733,9.767,9.800,9.833,9.867,9.900,9.933,9.967,10.000,10.033,10.067,10.100,10.133,10.167,10.200,10.233,10.267,10.300,10.333,10.367,10.400,10.433,10.467,10.500,10.533,10.567,10.600,10.633,10.667,10.700,10.733,10.767,10.800,10.833,10.867,10.900,10.933,10.967,11.000,11.033,11.067,11.100,11.133,11.167,11.200,11.233,11.267,11.300,11.333,11.367,11.400,11.433,11.467,11.500,11.533,11.567,11.600,11.633,11.667,11.700,11.733,11.767,11.800,11.833,11.867,11.900,11.933,11.967,12.000,12.033,12.067,12.100,12.133,12.167,12.200,12.233,12.267,12.300,12.333,12.367,12.400,12.433,12.467,12.500,12.533,12.567,12.600,12.633,12.667,12.700,12.733,12.767,12.800,12.833,12.867,12.900,12.933,12.967,13.001],\"CO2\":[445.189,443.284,440.908,443.431,442.366,444.094,445.152,445.656,447.968,447.998,443.950,442.547,439.313,438.225,441.433,441.190,443.804,445.173,446.494,445.278,452.073,458.844,470.828,478.147,488.338,502.126,522.057,545.519,579.881,616.245,641.154,676.288,701.938,720.464,746.933,765.830,779.098,794.173,810.624,825.967,838.340,854.355,876.382,886.208,898.408,921.718,942.848,953.812,978.956,990.321,1002.931,1017.361,1029.379,1041.028,1051.883,1067.220,1073.530,1079.738,1093.733,1104.814,1125.798,1141.115,1151.046,1160.053,1176.367,1193.665,1180.104,1015.334,864.746,802.681,774.455,728.268,697.326,676.063,657.555,640.564,606.534,595.925,577.753,553.605,530.213,524.968,523.153,521.534,512.944,505.297,502.056,502.463,505.248,507.477,509.171,511.313,513.780,520.393,529.137,532.798,530.111,523.964,521.574,519.052,510.294,509.982,514.349,518.396,524.603,521.003,519.448,523.313,527.460,528.326,526.355,527.008,529.968,534.019,535.616,533.514,530.552,522.348,524.243,532.021,539.127,538.836,526.186,517.509,507.993,493.703,485.632,479.527,471.584,472.226,468.206,463.099,461.038,458.980,456.354,458.615,459.162,462.963,465.558,468.448,475.207,480.323,488.962,527.992,579.613,606.594,611.218,617.023,635.927,651.079,676.647]}", "total_people":"2", "exposed_start":"08:30", @@ -308,8 +319,8 @@ Currently, the REST API contains two routing categories that provide the generat ### Development For testing new releases, use the PyPI Test instance by running the following command (directory independent): - + pip install --index-url https://test.pypi.org/simple --extra-index-url https://pypi.org/simple caimira - + !!! info `--extra-index-url` is necessary to resolve dependencies from PyPI. diff --git a/caimira/src/caimira/api/routes/base_handler.py b/caimira/src/caimira/api/routes/base_handler.py index f9044964..508c7938 100644 --- a/caimira/src/caimira/api/routes/base_handler.py +++ b/caimira/src/caimira/api/routes/base_handler.py @@ -1,13 +1,36 @@ +import os import tornado.web -class BaseRequestHandler(tornado.web.RequestHandler): +class CorsHandler(tornado.web.RequestHandler): + """Handler to manage CORS (Cross-Origin Resource Sharing) configuration. + + This handler implements CORS support by setting appropriate headers for cross-origin requests. + It checks allowed origins against environment variable CAIMIRA_ALLOWED_ORIGINS and enables + CORS for matching origins. + """ + def set_default_headers(self): - self.set_header("Access-Control-Allow-Origin", "*") - self.set_header("Access-Control-Allow-Headers", "x-requested-with") - self.set_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + allowed_origins = os.environ.get("CAIMIRA_ALLOWED_ORIGINS", None) + request_origin = self.request.headers.get("Origin", None) # can have value `null` + if allowed_origins and request_origin: + allowed_origins = [origin.lower().strip() for origin in allowed_origins.split(",")] + if request_origin.lower() in allowed_origins: + self.set_header("Access-Control-Allow-Origin", request_origin) + + def options(self, *args): + self.set_header("Access-Control-Allow-Methods", "POST, OPTIONS") + self.set_header("Access-Control-Allow-Headers", "Content-Type") + self.set_status(204) # No Content + + +class BaseRequestHandler(CorsHandler): + """Base handler for HTTP requests extending CorsHandler. + + This class provides base functionality for handling HTTP requests with CORS support. + It includes error handling capabilities by overriding the write_error method. + """ def write_error(self, status_code, **kwargs): self.set_status(status_code) self.write({"message": kwargs.get('exc_info')[1].__str__()}) - \ No newline at end of file diff --git a/caimira/tests/apps/test_api_app.py b/caimira/tests/apps/test_api_app.py new file mode 100644 index 00000000..b836aec1 --- /dev/null +++ b/caimira/tests/apps/test_api_app.py @@ -0,0 +1,41 @@ +import os +from tornado.testing import AsyncHTTPTestCase +from caimira.api.app import Application + +class TestAPIApp(AsyncHTTPTestCase): + def get_app(self): + return Application(debug=True) + + def test_api_app(self): + response = self.fetch("/") + assert response.code == 200 + + def test_cors(self): + # test unset env + response = self.fetch("/", method="OPTIONS", headers={"Origin": "http://example.com"}) + assert response.code == 204 + assert "Access-Control-Allow-Origin" not in response.headers + + # test None and empty value + os.environ["CAIMIRA_ALLOWED_ORIGINS"] = "" + response = self.fetch("/", method="OPTIONS", headers={"Origin": "http://example.com"}) + assert response.code == 204 + assert "Access-Control-Allow-Origin" not in response.headers + + # test allowing single domain + os.environ["CAIMIRA_ALLOWED_ORIGINS"] = "http://example.com" + response = self.fetch("/", method="OPTIONS", headers={"Origin": "http://example.com"}) + assert response.code == 204 + assert response.headers["Access-Control-Allow-Origin"] == "http://example.com" + + # test allowing multiple domains + os.environ["CAIMIRA_ALLOWED_ORIGINS"] = "http://example.com, http://example2.com" + response = self.fetch("/", method="OPTIONS", headers={"Origin": "http://example2.com"}) + assert response.code == 204 + assert response.headers["Access-Control-Allow-Origin"] == "http://example2.com" + + # test `null` value for Origin header + os.environ["CAIMIRA_ALLOWED_ORIGINS"] = "http://example.com" + response = self.fetch("/", method="OPTIONS", headers={"Origin": "null"}) + assert response.code == 204 + assert "Access-Control-Allow-Origin" not in response.headers