From 2ba3ced14e014ab5e21118af0929a658a8009fa9 Mon Sep 17 00:00:00 2001 From: vesp Date: Tue, 30 Dec 2025 12:54:26 +0100 Subject: [PATCH] Implement environment variable substitution in requests --- src/meson.build | 1 + src/models.py | 10 ++- src/request_tab_widget.py | 113 ++++++++++++++++++++++++++++++- src/tab_manager.py | 8 ++- src/variable_substitution.py | 125 +++++++++++++++++++++++++++++++++++ src/window.py | 62 +++++++++++++++-- 6 files changed, 309 insertions(+), 10 deletions(-) create mode 100644 src/variable_substitution.py diff --git a/src/meson.build b/src/meson.build index 49562d8..986d082 100644 --- a/src/meson.build +++ b/src/meson.build @@ -31,6 +31,7 @@ roster_sources = [ 'main.py', 'window.py', 'models.py', + 'variable_substitution.py', 'http_client.py', 'history_manager.py', 'project_manager.py', diff --git a/src/models.py b/src/models.py index 22494a8..3c75f59 100644 --- a/src/models.py +++ b/src/models.py @@ -203,6 +203,8 @@ class RequestTab: saved_request_id: Optional[str] = None # ID if from SavedRequest modified: bool = False # True if has unsaved changes original_request: Optional[HttpRequest] = None # For change detection + project_id: Optional[str] = None # Project association for environment variables + selected_environment_id: Optional[str] = None # Selected environment for variable substitution def is_modified(self) -> bool: """Check if current request differs from original.""" @@ -227,7 +229,9 @@ class RequestTab: 'response': self.response.to_dict() if self.response else None, 'saved_request_id': self.saved_request_id, 'modified': self.modified, - 'original_request': self.original_request.to_dict() if self.original_request else None + 'original_request': self.original_request.to_dict() if self.original_request else None, + 'project_id': self.project_id, + 'selected_environment_id': self.selected_environment_id } @classmethod @@ -240,5 +244,7 @@ class RequestTab: response=HttpResponse.from_dict(data['response']) if data.get('response') else None, saved_request_id=data.get('saved_request_id'), modified=data.get('modified', False), - original_request=HttpRequest.from_dict(data['original_request']) if data.get('original_request') else None + original_request=HttpRequest.from_dict(data['original_request']) if data.get('original_request') else None, + project_id=data.get('project_id'), + selected_environment_id=data.get('selected_environment_id') ) diff --git a/src/request_tab_widget.py b/src/request_tab_widget.py index 3a51e50..6d2ecde 100644 --- a/src/request_tab_widget.py +++ b/src/request_tab_widget.py @@ -29,7 +29,7 @@ import xml.dom.minidom class RequestTabWidget(Gtk.Box): """Widget representing a single request tab's UI.""" - def __init__(self, tab_id, request=None, response=None, **kwargs): + def __init__(self, tab_id, request=None, response=None, project_id=None, selected_environment_id=None, **kwargs): super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) self.tab_id = tab_id @@ -37,6 +37,10 @@ class RequestTabWidget(Gtk.Box): self.response = response self.modified = False self.original_request = None + self.project_id = project_id + self.selected_environment_id = selected_environment_id + self.project_manager = None # Will be injected from window + self.environment_dropdown = None # Will be created if project_id is set # Build the UI self._build_ui() @@ -83,6 +87,11 @@ class RequestTabWidget(Gtk.Box): url_clamp.set_child(url_box) self.append(url_clamp) + # Environment Selector (only if project_id is set) + env_selector = self._build_environment_selector() + if env_selector: + self.append(env_selector) + # Vertical Paned: Main Content | History Panel vpane = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL) vpane.set_vexpand(True) @@ -564,3 +573,105 @@ class RequestTabWidget(Gtk.Box): print(f"Failed to format body: {e}") return body + + def _build_environment_selector(self): + """Build environment selector UI. Returns None if no project_id.""" + if not self.project_id: + return None + + # Create horizontal box for environment selector + env_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + env_box.set_margin_start(12) + env_box.set_margin_end(12) + env_box.set_margin_top(6) + env_box.set_margin_bottom(6) + + # Label + label = Gtk.Label(label="Environment:") + label.add_css_class("dim-label") + env_box.append(label) + + # Dropdown (will be populated when project_manager is injected) + self.environment_dropdown = Gtk.DropDown() + self.environment_dropdown.set_enable_search(False) + env_box.append(self.environment_dropdown) + + # Connect signal + self.environment_dropdown.connect("notify::selected", self._on_environment_changed) + + # Populate if project_manager is already available + if self.project_manager: + self._populate_environment_dropdown() + + return env_box + + def _populate_environment_dropdown(self): + """Populate environment dropdown with project's environments.""" + if not self.environment_dropdown or not self.project_manager or not self.project_id: + return + + # Get project + projects = self.project_manager.load_projects() + project = None + for p in projects: + if p.id == self.project_id: + project = p + break + + if not project: + return + + # Build string list with "None" + environment names + string_list = Gtk.StringList() + string_list.append("None") + + # Track environment IDs (index 0 is None) + self.environment_ids = [None] + + for env in project.environments: + string_list.append(env.name) + self.environment_ids.append(env.id) + + self.environment_dropdown.set_model(string_list) + + # Select current environment + if self.selected_environment_id: + try: + index = self.environment_ids.index(self.selected_environment_id) + self.environment_dropdown.set_selected(index) + except ValueError: + self.environment_dropdown.set_selected(0) # Default to "None" + else: + self.environment_dropdown.set_selected(0) # Default to "None" + + def _on_environment_changed(self, dropdown, _param): + """Handle environment selection change.""" + if not hasattr(self, 'environment_ids'): + return + + selected_index = dropdown.get_selected() + if selected_index < len(self.environment_ids): + self.selected_environment_id = self.environment_ids[selected_index] + + def get_selected_environment(self): + """Get the currently selected environment object.""" + if not self.selected_environment_id or not self.project_manager or not self.project_id: + return None + + # Load projects + projects = self.project_manager.load_projects() + project = None + for p in projects: + if p.id == self.project_id: + project = p + break + + if not project: + return None + + # Find environment + for env in project.environments: + if env.id == self.selected_environment_id: + return env + + return None diff --git a/src/tab_manager.py b/src/tab_manager.py index f39ee93..211797d 100644 --- a/src/tab_manager.py +++ b/src/tab_manager.py @@ -34,7 +34,9 @@ class TabManager: def create_tab(self, name: str, request: HttpRequest, saved_request_id: Optional[str] = None, - response: Optional[HttpResponse] = None) -> RequestTab: + response: Optional[HttpResponse] = None, + project_id: Optional[str] = None, + selected_environment_id: Optional[str] = None) -> RequestTab: """Create a new tab and add it to the collection.""" tab_id = str(uuid.uuid4()) @@ -58,7 +60,9 @@ class TabManager: response=response, saved_request_id=saved_request_id, modified=False, - original_request=original_request + original_request=original_request, + project_id=project_id, + selected_environment_id=selected_environment_id ) self.tabs.append(tab) diff --git a/src/variable_substitution.py b/src/variable_substitution.py new file mode 100644 index 0000000..257dbfd --- /dev/null +++ b/src/variable_substitution.py @@ -0,0 +1,125 @@ +# variable_substitution.py +# +# Copyright 2025 Pavel Baksy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import re +from typing import Dict, List, Tuple, Set +from .models import HttpRequest, Environment + + +class VariableSubstitution: + """Handles variable substitution with {{variable_name}} syntax.""" + + # Pattern to match {{variable_name}} where variable_name is alphanumeric + underscore + VARIABLE_PATTERN = re.compile(r'\{\{(\w+)\}\}') + + @staticmethod + def find_variables(text: str) -> List[str]: + """ + Extract all {{variable_name}} references from text. + + Args: + text: The text to search for variable references + + Returns: + List of variable names found (without the {{ }} delimiters) + """ + if not text: + return [] + return VariableSubstitution.VARIABLE_PATTERN.findall(text) + + @staticmethod + def substitute(text: str, variables: Dict[str, str]) -> Tuple[str, List[str]]: + """ + Replace {{variable_name}} with values from variables dict. + + Args: + text: The text containing variable placeholders + variables: Dictionary mapping variable names to their values + + Returns: + Tuple of (substituted_text, list_of_undefined_variables) + """ + if not text: + return text, [] + + undefined_vars = [] + + def replace_var(match): + var_name = match.group(1) + if var_name in variables: + value = variables[var_name] + # Replace with value or empty string if value is None/empty + return value if value else "" + else: + # Variable not defined - track it and replace with empty string + undefined_vars.append(var_name) + return "" + + substituted = VariableSubstitution.VARIABLE_PATTERN.sub(replace_var, text) + return substituted, undefined_vars + + @staticmethod + def substitute_request(request: HttpRequest, environment: Environment) -> Tuple[HttpRequest, Set[str]]: + """ + Substitute variables in all request fields (URL, headers, body). + + Args: + request: The HTTP request with variable placeholders + environment: The environment containing variable values + + Returns: + Tuple of (new_request_with_substitutions, set_of_undefined_variables) + """ + all_undefined = set() + + # Substitute URL + new_url, url_undefined = VariableSubstitution.substitute( + request.url, environment.variables + ) + all_undefined.update(url_undefined) + + # Substitute headers (both keys and values) + new_headers = {} + for key, value in request.headers.items(): + new_key, key_undefined = VariableSubstitution.substitute( + key, environment.variables + ) + new_value, value_undefined = VariableSubstitution.substitute( + value, environment.variables + ) + all_undefined.update(key_undefined) + all_undefined.update(value_undefined) + new_headers[new_key] = new_value + + # Substitute body + new_body, body_undefined = VariableSubstitution.substitute( + request.body, environment.variables + ) + all_undefined.update(body_undefined) + + # Create new HttpRequest with substituted values + new_request = HttpRequest( + method=request.method, + url=new_url, + headers=new_headers, + body=new_body, + syntax=request.syntax + ) + + return new_request, all_undefined diff --git a/src/window.py b/src/window.py index e7cb8f7..61b2c9a 100644 --- a/src/window.py +++ b/src/window.py @@ -150,6 +150,11 @@ class RosterWindow(Adw.ApplicationWindow): color: @accent_fg_color; font-weight: 600; } + + /* Warning styling for undefined variables */ + entry.warning { + background-color: mix(@warning_bg_color, @view_bg_color, 0.3); + } """) Gtk.StyleContext.add_provider_for_display( @@ -294,18 +299,25 @@ class RosterWindow(Adw.ApplicationWindow): return f"{base_name} (copy {counter})" - def _create_new_tab(self, name="New Request", request=None, saved_request_id=None, response=None): + def _create_new_tab(self, name="New Request", request=None, saved_request_id=None, response=None, + project_id=None, selected_environment_id=None): """Create a new tab with RequestTabWidget.""" # Create tab in tab manager if not request: request = HttpRequest(method="GET", url="", headers={}, body="", syntax="RAW") - tab = self.tab_manager.create_tab(name, request, saved_request_id, response) + tab = self.tab_manager.create_tab(name, request, saved_request_id, response, + project_id, selected_environment_id) self.current_tab_id = tab.id # Create RequestTabWidget for this tab - widget = RequestTabWidget(tab.id, request, response) + widget = RequestTabWidget(tab.id, request, response, project_id, selected_environment_id) widget.original_request = tab.original_request + widget.project_manager = self.project_manager # Inject project manager + + # Populate environment dropdown if project_id is set + if project_id and hasattr(widget, '_populate_environment_dropdown'): + widget._populate_environment_dropdown() # Connect to send button widget.send_button.connect("clicked", lambda btn: self._on_send_clicked(widget)) @@ -327,6 +339,21 @@ class RosterWindow(Adw.ApplicationWindow): return tab.id + def _get_project_for_saved_request(self, saved_request_id): + """Find which project contains a saved request. Returns (project_id, default_env_id) or (None, None).""" + if not saved_request_id: + return None, None + + projects = self.project_manager.load_projects() + for project in projects: + for request in project.requests: + if request.id == saved_request_id: + # Found the project - get default environment (first one) + default_env_id = project.environments[0].id if project.environments else None + return project.id, default_env_id + + return None, None + def _on_tab_modified_changed(self, page, modified): """Update tab indicator when modified state changes.""" # Update the tab title with/without modified indicator @@ -385,6 +412,17 @@ class RosterWindow(Adw.ApplicationWindow): self._show_toast("Please enter a URL") return + # Apply variable substitution if environment is selected + substituted_request = request + if widget.selected_environment_id: + env = widget.get_selected_environment() + if env: + from .variable_substitution import VariableSubstitution + substituted_request, undefined = VariableSubstitution.substitute_request(request, env) + # Log undefined variables for debugging + if undefined: + print(f"Warning: Undefined variables in request: {', '.join(undefined)}") + # Disable send button during request widget.send_button.set_sensitive(False) widget.send_button.set_label("Sending...") @@ -421,7 +459,7 @@ class RosterWindow(Adw.ApplicationWindow): # Refresh history panel to show new entry self._load_history() - self.http_client.execute_request_async(request, callback, None) + self.http_client.execute_request_async(substituted_request, callback, None) # History and Project Management def _load_history(self): @@ -874,10 +912,21 @@ class RosterWindow(Adw.ApplicationWindow): current_tab = self.page_to_tab.get(page) if widget and current_tab: + # Get project context + project_id, default_env_id = self._get_project_for_saved_request(link_to_saved) + # Update the tab metadata current_tab.name = tab_name current_tab.request = req current_tab.saved_request_id = link_to_saved + current_tab.project_id = project_id + current_tab.selected_environment_id = default_env_id + + # Update widget with project context + widget.project_id = project_id + widget.selected_environment_id = default_env_id + if project_id and hasattr(widget, '_populate_environment_dropdown'): + widget._populate_environment_dropdown() # Update the tab page title page.set_title(tab_name) @@ -905,10 +954,13 @@ class RosterWindow(Adw.ApplicationWindow): else: # Current tab has changes or is not a "New Request" # Create a new tab + project_id, default_env_id = self._get_project_for_saved_request(link_to_saved) tab_id = self._create_new_tab( name=tab_name, request=req, - saved_request_id=link_to_saved + saved_request_id=link_to_saved, + project_id=project_id, + selected_environment_id=default_env_id ) # If it's a copy, clear the original_request to mark as unsaved