Implement environment variable substitution in requests
This commit is contained in:
parent
24b1db4b9b
commit
2ba3ced14e
@ -31,6 +31,7 @@ roster_sources = [
|
||||
'main.py',
|
||||
'window.py',
|
||||
'models.py',
|
||||
'variable_substitution.py',
|
||||
'http_client.py',
|
||||
'history_manager.py',
|
||||
'project_manager.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')
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
125
src/variable_substitution.py
Normal file
125
src/variable_substitution.py
Normal 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
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user