Implement environment variable substitution in requests

This commit is contained in:
Pavel Baksy 2025-12-30 12:54:26 +01:00
parent dfe2a9a360
commit 89f50d1304
6 changed files with 309 additions and 10 deletions

View File

@ -31,6 +31,7 @@ roster_sources = [
'main.py', 'main.py',
'window.py', 'window.py',
'models.py', 'models.py',
'variable_substitution.py',
'http_client.py', 'http_client.py',
'history_manager.py', 'history_manager.py',
'project_manager.py', 'project_manager.py',

View File

@ -203,6 +203,8 @@ class RequestTab:
saved_request_id: Optional[str] = None # ID if from SavedRequest saved_request_id: Optional[str] = None # ID if from SavedRequest
modified: bool = False # True if has unsaved changes modified: bool = False # True if has unsaved changes
original_request: Optional[HttpRequest] = None # For change detection 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: def is_modified(self) -> bool:
"""Check if current request differs from original.""" """Check if current request differs from original."""
@ -227,7 +229,9 @@ class RequestTab:
'response': self.response.to_dict() if self.response else None, 'response': self.response.to_dict() if self.response else None,
'saved_request_id': self.saved_request_id, 'saved_request_id': self.saved_request_id,
'modified': self.modified, '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 @classmethod
@ -240,5 +244,7 @@ class RequestTab:
response=HttpResponse.from_dict(data['response']) if data.get('response') else None, response=HttpResponse.from_dict(data['response']) if data.get('response') else None,
saved_request_id=data.get('saved_request_id'), saved_request_id=data.get('saved_request_id'),
modified=data.get('modified', False), 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')
) )

View File

@ -29,7 +29,7 @@ import xml.dom.minidom
class RequestTabWidget(Gtk.Box): class RequestTabWidget(Gtk.Box):
"""Widget representing a single request tab's UI.""" """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) super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs)
self.tab_id = tab_id self.tab_id = tab_id
@ -37,6 +37,10 @@ class RequestTabWidget(Gtk.Box):
self.response = response self.response = response
self.modified = False self.modified = False
self.original_request = None 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 # Build the UI
self._build_ui() self._build_ui()
@ -83,6 +87,11 @@ class RequestTabWidget(Gtk.Box):
url_clamp.set_child(url_box) url_clamp.set_child(url_box)
self.append(url_clamp) 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 # Vertical Paned: Main Content | History Panel
vpane = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL) vpane = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
vpane.set_vexpand(True) vpane.set_vexpand(True)
@ -564,3 +573,105 @@ class RequestTabWidget(Gtk.Box):
print(f"Failed to format body: {e}") print(f"Failed to format body: {e}")
return body 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

View File

@ -34,7 +34,9 @@ class TabManager:
def create_tab(self, name: str, request: HttpRequest, def create_tab(self, name: str, request: HttpRequest,
saved_request_id: Optional[str] = None, 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.""" """Create a new tab and add it to the collection."""
tab_id = str(uuid.uuid4()) tab_id = str(uuid.uuid4())
@ -58,7 +60,9 @@ class TabManager:
response=response, response=response,
saved_request_id=saved_request_id, saved_request_id=saved_request_id,
modified=False, modified=False,
original_request=original_request original_request=original_request,
project_id=project_id,
selected_environment_id=selected_environment_id
) )
self.tabs.append(tab) self.tabs.append(tab)

View File

@ -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 <https://www.gnu.org/licenses/>.
#
# 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

View File

@ -150,6 +150,11 @@ class RosterWindow(Adw.ApplicationWindow):
color: @accent_fg_color; color: @accent_fg_color;
font-weight: 600; 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( Gtk.StyleContext.add_provider_for_display(
@ -294,18 +299,25 @@ class RosterWindow(Adw.ApplicationWindow):
return f"{base_name} (copy {counter})" 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 a new tab with RequestTabWidget."""
# Create tab in tab manager # Create tab in tab manager
if not request: if not request:
request = HttpRequest(method="GET", url="", headers={}, body="", syntax="RAW") 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 self.current_tab_id = tab.id
# Create RequestTabWidget for this tab # 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.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 # Connect to send button
widget.send_button.connect("clicked", lambda btn: self._on_send_clicked(widget)) widget.send_button.connect("clicked", lambda btn: self._on_send_clicked(widget))
@ -327,6 +339,21 @@ class RosterWindow(Adw.ApplicationWindow):
return tab.id 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): def _on_tab_modified_changed(self, page, modified):
"""Update tab indicator when modified state changes.""" """Update tab indicator when modified state changes."""
# Update the tab title with/without modified indicator # Update the tab title with/without modified indicator
@ -385,6 +412,17 @@ class RosterWindow(Adw.ApplicationWindow):
self._show_toast("Please enter a URL") self._show_toast("Please enter a URL")
return 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 # Disable send button during request
widget.send_button.set_sensitive(False) widget.send_button.set_sensitive(False)
widget.send_button.set_label("Sending...") widget.send_button.set_label("Sending...")
@ -421,7 +459,7 @@ class RosterWindow(Adw.ApplicationWindow):
# Refresh history panel to show new entry # Refresh history panel to show new entry
self._load_history() 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 # History and Project Management
def _load_history(self): def _load_history(self):
@ -874,10 +912,21 @@ class RosterWindow(Adw.ApplicationWindow):
current_tab = self.page_to_tab.get(page) current_tab = self.page_to_tab.get(page)
if widget and current_tab: 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 # Update the tab metadata
current_tab.name = tab_name current_tab.name = tab_name
current_tab.request = req current_tab.request = req
current_tab.saved_request_id = link_to_saved 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 # Update the tab page title
page.set_title(tab_name) page.set_title(tab_name)
@ -905,10 +954,13 @@ class RosterWindow(Adw.ApplicationWindow):
else: else:
# Current tab has changes or is not a "New Request" # Current tab has changes or is not a "New Request"
# Create a new tab # Create a new tab
project_id, default_env_id = self._get_project_for_saved_request(link_to_saved)
tab_id = self._create_new_tab( tab_id = self._create_new_tab(
name=tab_name, name=tab_name,
request=req, 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 # If it's a copy, clear the original_request to mark as unsaved