From e6ff5186408a52ede5c86232f10ccb9698313436 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Mon, 8 Dec 2025 10:33:38 +0100 Subject: [PATCH 1/5] feat(extensions): add new blueprint and tests for extension imports --- .../routes/blueprint_routes.py | 77 ++++++++++++++++++ .../routes/schemas/__init__.py | 1 + .../routes/schemas/import_extension.json | 10 +++ .../routes/schemas/import_extension.py | 10 +++ tests/test_models_routes.py | 79 +++++++++++++++++++ 5 files changed, 177 insertions(+) create mode 100644 src/opengeodeweb_back/routes/schemas/import_extension.json create mode 100644 src/opengeodeweb_back/routes/schemas/import_extension.py diff --git a/src/opengeodeweb_back/routes/blueprint_routes.py b/src/opengeodeweb_back/routes/blueprint_routes.py index 654c111..d5860f5 100644 --- a/src/opengeodeweb_back/routes/blueprint_routes.py +++ b/src/opengeodeweb_back/routes/blueprint_routes.py @@ -502,3 +502,80 @@ def import_project() -> flask.Response: except KeyError: snapshot = {} return flask.make_response({"snapshot": snapshot}, 200) + + +@routes.route( + schemas_dict["import_extension"]["route"], + methods=schemas_dict["import_extension"]["methods"], +) +def import_extension() -> flask.Response: + """Import a .vext extension file and extract its contents.""" + utils_functions.validate_request(flask.request, schemas_dict["import_extension"]) + + if "file" not in flask.request.files: + flask.abort(400, "No .vext file provided under 'file'") + + vext_file = flask.request.files["file"] + assert vext_file.filename is not None + filename = werkzeug.utils.secure_filename(os.path.basename(vext_file.filename)) + + if not filename.lower().endswith(".vext"): + flask.abort(400, "Uploaded file must be a .vext") + + # Create extensions directory in the data folder + data_folder_path: str = flask.current_app.config.get("DATA_FOLDER_PATH", "") + extensions_folder = os.path.join(data_folder_path, "extensions") + os.makedirs(extensions_folder, exist_ok=True) + + # Extract extension name from filename (e.g., "vease-modeling-0.0.0.vext" -> "vease-modeling") + extension_name = ( + filename.rsplit("-", 1)[0] if "-" in filename else filename.replace(".vext", "") + ) + extension_path = os.path.join(extensions_folder, extension_name) + + # Remove existing extension if present + if os.path.exists(extension_path): + shutil.rmtree(extension_path) + + os.makedirs(extension_path, exist_ok=True) + + # Extract the .vext file + vext_file.stream.seek(0) + with zipfile.ZipFile(vext_file.stream) as zip_archive: + zip_archive.extractall(extension_path) + + # Find the extracted files + dist_path = os.path.join(extension_path, "dist") + if not os.path.exists(dist_path): + flask.abort(400, "Invalid .vext file: missing dist folder") + + # Look for the backend executable and frontend JS + backend_executable = None + frontend_file = None + + for file in os.listdir(dist_path): + file_path = os.path.join(dist_path, file) + if os.path.isfile(file_path): + if file.endswith(".es.js"): + frontend_file = file_path + elif not file.endswith(".js") and not file.endswith(".css"): + # Assume it's the backend executable + backend_executable = file_path + # Make it executable + os.chmod(backend_executable, 0o755) + + if not frontend_file: + flask.abort(400, "Invalid .vext file: missing frontend JavaScript") + + if not backend_executable: + flask.abort(400, "Invalid .vext file: missing backend executable") + + return flask.make_response( + { + "extension_name": extension_name, + "frontend_path": frontend_file, + "backend_path": backend_executable, + "extension_folder": extension_path, + }, + 200, + ) diff --git a/src/opengeodeweb_back/routes/schemas/__init__.py b/src/opengeodeweb_back/routes/schemas/__init__.py index 4ad76b8..4ca6775 100644 --- a/src/opengeodeweb_back/routes/schemas/__init__.py +++ b/src/opengeodeweb_back/routes/schemas/__init__.py @@ -15,3 +15,4 @@ from .cell_attribute_names import * from .allowed_objects import * from .allowed_files import * +from .import_extension import * diff --git a/src/opengeodeweb_back/routes/schemas/import_extension.json b/src/opengeodeweb_back/routes/schemas/import_extension.json new file mode 100644 index 0000000..4d80f55 --- /dev/null +++ b/src/opengeodeweb_back/routes/schemas/import_extension.json @@ -0,0 +1,10 @@ +{ + "route": "/import_extension", + "methods": [ + "POST" + ], + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false +} diff --git a/src/opengeodeweb_back/routes/schemas/import_extension.py b/src/opengeodeweb_back/routes/schemas/import_extension.py new file mode 100644 index 0000000..256cb5f --- /dev/null +++ b/src/opengeodeweb_back/routes/schemas/import_extension.py @@ -0,0 +1,10 @@ +from dataclasses_json import DataClassJsonMixin +from dataclasses import dataclass + + +@dataclass +class ImportExtension(DataClassJsonMixin): + def __post_init__(self) -> None: + print(self, flush=True) + + pass diff --git a/tests/test_models_routes.py b/tests/test_models_routes.py index 379e5b1..c187048 100644 --- a/tests/test_models_routes.py +++ b/tests/test_models_routes.py @@ -185,3 +185,82 @@ def test_save_viewable_workflow_from_object(client: FlaskClient) -> None: assert isinstance(data_id, str) and len(data_id) > 0 assert response.get_json()["geode_object_type"] == "EdgedCurve3D" assert response.get_json()["viewable_file"].endswith(".vtp") + + +def test_import_extension_route(client: FlaskClient, tmp_path: Path) -> None: + """Test importing a .vext extension file.""" + route = "/opengeodeweb_back/import_extension" + original_data_folder = client.application.config["DATA_FOLDER_PATH"] + client.application.config["DATA_FOLDER_PATH"] = os.path.join( + str(tmp_path), "extension_test_data" + ) + vext_path = tmp_path / "test-extension-1.0.0.vext" + with zipfile.ZipFile(vext_path, "w", compression=zipfile.ZIP_DEFLATED) as zipf: + zipf.writestr( + "dist/test-extension-extension.es.js", + "export const metadata = { id: 'test-extension', name: 'Test Extension' };", + ) + zipf.writestr("dist/test-extension-back", "#!/bin/bash\necho 'mock backend'") + zipf.writestr("dist/test-extension.css", ".test { color: red; }") + with open(vext_path, "rb") as f: + response = client.post( + route, + data={"file": (f, "test-extension-1.0.0.vext")}, + content_type="multipart/form-data", + ) + assert response.status_code == 200 + json_data = response.get_json() + assert "extension_name" in json_data + assert "frontend_path" in json_data + assert "backend_path" in json_data + assert "extension_folder" in json_data + assert json_data["extension_name"] == "test-extension" + extensions_folder = os.path.join( + client.application.config["DATA_FOLDER_PATH"], "extensions" + ) + extension_path = os.path.join(extensions_folder, "test-extension") + assert os.path.exists(extension_path) + dist_path = os.path.join(extension_path, "dist") + assert os.path.exists(dist_path) + frontend_js = json_data["frontend_path"] + assert os.path.exists(frontend_js) + assert frontend_js.endswith("-extension.es.js") + backend_exec = json_data["backend_path"] + assert os.path.exists(backend_exec) + assert os.access(backend_exec, os.X_OK) + client.application.config["DATA_FOLDER_PATH"] = original_data_folder + + +def test_import_extension_invalid_file(client: FlaskClient, tmp_path: Path) -> None: + """Test importing an invalid .vext file (missing dist folder).""" + route = "/opengeodeweb_back/import_extension" + original_data_folder = client.application.config["DATA_FOLDER_PATH"] + client.application.config["DATA_FOLDER_PATH"] = os.path.join( + str(tmp_path), "extension_invalid_test" + ) + vext_path = tmp_path / "invalid-extension.vext" + with zipfile.ZipFile(vext_path, "w") as zipf: + zipf.writestr("README.md", "This is invalid") + with open(vext_path, "rb") as f: + response = client.post( + route, + data={"file": (f, "invalid-extension.vext")}, + content_type="multipart/form-data", + ) + assert response.status_code == 400 + client.application.config["DATA_FOLDER_PATH"] = original_data_folder + + +def test_import_extension_wrong_extension(client: FlaskClient, tmp_path: Path) -> None: + """Test uploading a file with wrong extension.""" + route = "/opengeodeweb_back/import_extension" + wrong_file = tmp_path / "not-an-extension.zip" + with open(wrong_file, "wb") as f: + f.write(b"test content") + with open(wrong_file, "rb") as f: + response = client.post( + route, + data={"file": (f, "not-an-extension.zip")}, + content_type="multipart/form-data", + ) + assert response.status_code == 400 From 7090cc1cbef620cbbb6e5f2a8d36430403a572ae Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Mon, 8 Dec 2025 11:59:44 +0100 Subject: [PATCH 2/5] frontend_file as read --- opengeodeweb_back_schemas.json | 11 +++++++++++ src/opengeodeweb_back/routes/blueprint_routes.py | 9 ++++++--- src/opengeodeweb_back/routes/schemas/__init__.py | 2 +- tests/test_models_routes.py | 13 ++++++++----- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/opengeodeweb_back_schemas.json b/opengeodeweb_back_schemas.json index 939f320..4249482 100644 --- a/opengeodeweb_back_schemas.json +++ b/opengeodeweb_back_schemas.json @@ -343,6 +343,17 @@ "required": [], "additionalProperties": false }, + "import_extension": { + "$id": "opengeodeweb_back/import_extension", + "route": "/import_extension", + "methods": [ + "POST" + ], + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, "geographic_coordinate_systems": { "$id": "opengeodeweb_back/geographic_coordinate_systems", "route": "/geographic_coordinate_systems", diff --git a/src/opengeodeweb_back/routes/blueprint_routes.py b/src/opengeodeweb_back/routes/blueprint_routes.py index d5860f5..040fbd7 100644 --- a/src/opengeodeweb_back/routes/blueprint_routes.py +++ b/src/opengeodeweb_back/routes/blueprint_routes.py @@ -566,16 +566,19 @@ def import_extension() -> flask.Response: if not frontend_file: flask.abort(400, "Invalid .vext file: missing frontend JavaScript") - if not backend_executable: flask.abort(400, "Invalid .vext file: missing backend executable") + # Read the frontend JS content + assert frontend_file is not None + with open(frontend_file, "r", encoding="utf-8") as f: + frontend_content = f.read() + return flask.make_response( { "extension_name": extension_name, - "frontend_path": frontend_file, + "frontend_content": frontend_content, "backend_path": backend_executable, - "extension_folder": extension_path, }, 200, ) diff --git a/src/opengeodeweb_back/routes/schemas/__init__.py b/src/opengeodeweb_back/routes/schemas/__init__.py index 4ca6775..5d37ca1 100644 --- a/src/opengeodeweb_back/routes/schemas/__init__.py +++ b/src/opengeodeweb_back/routes/schemas/__init__.py @@ -9,10 +9,10 @@ from .kill import * from .inspect_file import * from .import_project import * +from .import_extension import * from .geographic_coordinate_systems import * from .geode_objects_and_output_extensions import * from .export_project import * from .cell_attribute_names import * from .allowed_objects import * from .allowed_files import * -from .import_extension import * diff --git a/tests/test_models_routes.py b/tests/test_models_routes.py index c187048..1c9fd11 100644 --- a/tests/test_models_routes.py +++ b/tests/test_models_routes.py @@ -211,9 +211,8 @@ def test_import_extension_route(client: FlaskClient, tmp_path: Path) -> None: assert response.status_code == 200 json_data = response.get_json() assert "extension_name" in json_data - assert "frontend_path" in json_data + assert "frontend_content" in json_data assert "backend_path" in json_data - assert "extension_folder" in json_data assert json_data["extension_name"] == "test-extension" extensions_folder = os.path.join( client.application.config["DATA_FOLDER_PATH"], "extensions" @@ -222,9 +221,13 @@ def test_import_extension_route(client: FlaskClient, tmp_path: Path) -> None: assert os.path.exists(extension_path) dist_path = os.path.join(extension_path, "dist") assert os.path.exists(dist_path) - frontend_js = json_data["frontend_path"] - assert os.path.exists(frontend_js) - assert frontend_js.endswith("-extension.es.js") + + # Verify frontend content is returned + frontend_content = json_data["frontend_content"] + assert isinstance(frontend_content, str) + assert len(frontend_content) > 0 + assert "export const metadata" in frontend_content + backend_exec = json_data["backend_path"] assert os.path.exists(backend_exec) assert os.access(backend_exec, os.X_OK) From 56b7696904999452550fe5f1fa23e3fa38da18cf Mon Sep 17 00:00:00 2001 From: MaxNumerique <144453705+MaxNumerique@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:51:11 +0000 Subject: [PATCH 3/5] Apply prepare changes --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8325a25..08d64bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -60,4 +60,3 @@ werkzeug==3.1.2 # flask # flask-cors -opengeodeweb-microservice==1.*,>=1.0.10rc1 From 35ead1d0f63f345b961049a733ff580ff239ce5a Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Tue, 9 Dec 2025 14:10:23 +0100 Subject: [PATCH 4/5] EXTENSION_FOLDER_PATH in config --- src/opengeodeweb_back/app.py | 3 +++ src/opengeodeweb_back/app_config.py | 2 ++ src/opengeodeweb_back/routes/blueprint_routes.py | 3 +-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/opengeodeweb_back/app.py b/src/opengeodeweb_back/app.py index bf16739..9b60898 100644 --- a/src/opengeodeweb_back/app.py +++ b/src/opengeodeweb_back/app.py @@ -147,6 +147,9 @@ def run_server() -> None: ) args = parser.parse_args() app.config.update(DATA_FOLDER_PATH=args.data_folder_path) + app.config.update( + EXTENSIONS_FOLDER_PATH=os.path.join(args.data_folder_path, "extensions") + ) app.config.update(UPLOAD_FOLDER=args.upload_folder_path) app.config.update(MINUTES_BEFORE_TIMEOUT=args.timeout) flask_cors.CORS(app, origins=args.allowed_origins) diff --git a/src/opengeodeweb_back/app_config.py b/src/opengeodeweb_back/app_config.py index 095b275..2b17c4d 100644 --- a/src/opengeodeweb_back/app_config.py +++ b/src/opengeodeweb_back/app_config.py @@ -24,6 +24,7 @@ class ProdConfig(Config): MINUTES_BEFORE_TIMEOUT = "1" SECONDS_BETWEEN_SHUTDOWNS = "10" DATA_FOLDER_PATH = "/data" + EXTENSIONS_FOLDER_PATH = os.path.join(DATA_FOLDER_PATH, "extensions") class DevConfig(Config): @@ -33,3 +34,4 @@ class DevConfig(Config): SECONDS_BETWEEN_SHUTDOWNS = "10" BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_FOLDER_PATH = os.path.join(BASE_DIR, "data") + EXTENSIONS_FOLDER_PATH = os.path.join(DATA_FOLDER_PATH, "extensions") diff --git a/src/opengeodeweb_back/routes/blueprint_routes.py b/src/opengeodeweb_back/routes/blueprint_routes.py index 040fbd7..e65fb7b 100644 --- a/src/opengeodeweb_back/routes/blueprint_routes.py +++ b/src/opengeodeweb_back/routes/blueprint_routes.py @@ -523,8 +523,7 @@ def import_extension() -> flask.Response: flask.abort(400, "Uploaded file must be a .vext") # Create extensions directory in the data folder - data_folder_path: str = flask.current_app.config.get("DATA_FOLDER_PATH", "") - extensions_folder = os.path.join(data_folder_path, "extensions") + extensions_folder = flask.current_app.config["EXTENSIONS_FOLDER_PATH"] os.makedirs(extensions_folder, exist_ok=True) # Extract extension name from filename (e.g., "vease-modeling-0.0.0.vext" -> "vease-modeling") From 069cc6a19c043ab0eb769cdb896e44b03b879a59 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Tue, 9 Dec 2025 14:36:35 +0100 Subject: [PATCH 5/5] fix test with EXTENSION_FOLDER_PATH --- src/opengeodeweb_back/routes/blueprint_routes.py | 4 ---- tests/test_models_routes.py | 12 ++++++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/opengeodeweb_back/routes/blueprint_routes.py b/src/opengeodeweb_back/routes/blueprint_routes.py index e65fb7b..6a17e30 100644 --- a/src/opengeodeweb_back/routes/blueprint_routes.py +++ b/src/opengeodeweb_back/routes/blueprint_routes.py @@ -526,7 +526,6 @@ def import_extension() -> flask.Response: extensions_folder = flask.current_app.config["EXTENSIONS_FOLDER_PATH"] os.makedirs(extensions_folder, exist_ok=True) - # Extract extension name from filename (e.g., "vease-modeling-0.0.0.vext" -> "vease-modeling") extension_name = ( filename.rsplit("-", 1)[0] if "-" in filename else filename.replace(".vext", "") ) @@ -558,9 +557,7 @@ def import_extension() -> flask.Response: if file.endswith(".es.js"): frontend_file = file_path elif not file.endswith(".js") and not file.endswith(".css"): - # Assume it's the backend executable backend_executable = file_path - # Make it executable os.chmod(backend_executable, 0o755) if not frontend_file: @@ -568,7 +565,6 @@ def import_extension() -> flask.Response: if not backend_executable: flask.abort(400, "Invalid .vext file: missing backend executable") - # Read the frontend JS content assert frontend_file is not None with open(frontend_file, "r", encoding="utf-8") as f: frontend_content = f.read() diff --git a/tests/test_models_routes.py b/tests/test_models_routes.py index 1c9fd11..b08ad3d 100644 --- a/tests/test_models_routes.py +++ b/tests/test_models_routes.py @@ -191,8 +191,10 @@ def test_import_extension_route(client: FlaskClient, tmp_path: Path) -> None: """Test importing a .vext extension file.""" route = "/opengeodeweb_back/import_extension" original_data_folder = client.application.config["DATA_FOLDER_PATH"] - client.application.config["DATA_FOLDER_PATH"] = os.path.join( - str(tmp_path), "extension_test_data" + new_data_folder = os.path.join(str(tmp_path), "extension_test_data") + client.application.config["DATA_FOLDER_PATH"] = new_data_folder + client.application.config["EXTENSIONS_FOLDER_PATH"] = os.path.join( + new_data_folder, "extensions" ) vext_path = tmp_path / "test-extension-1.0.0.vext" with zipfile.ZipFile(vext_path, "w", compression=zipfile.ZIP_DEFLATED) as zipf: @@ -238,8 +240,10 @@ def test_import_extension_invalid_file(client: FlaskClient, tmp_path: Path) -> N """Test importing an invalid .vext file (missing dist folder).""" route = "/opengeodeweb_back/import_extension" original_data_folder = client.application.config["DATA_FOLDER_PATH"] - client.application.config["DATA_FOLDER_PATH"] = os.path.join( - str(tmp_path), "extension_invalid_test" + new_data_folder = os.path.join(str(tmp_path), "extension_invalid_test") + client.application.config["DATA_FOLDER_PATH"] = new_data_folder + client.application.config["EXTENSIONS_FOLDER_PATH"] = os.path.join( + new_data_folder, "extensions" ) vext_path = tmp_path / "invalid-extension.vext" with zipfile.ZipFile(vext_path, "w") as zipf: