diff --git a/src/posting/app.py b/src/posting/app.py index 0a667fa1d..917ae7a17 100644 --- a/src/posting/app.py +++ b/src/posting/app.py @@ -832,6 +832,7 @@ def load_request_model( self.request_body_text_area.text = request_model.body.content self.request_editor.request_body_type_select.value = "text-body-editor" self.request_editor.form_editor.replace_all_rows([]) + self.request_editor.multipart_editor.replace_all_rows([]) elif request_model.body.form_data: self.request_editor.form_editor.replace_all_rows( ( @@ -842,10 +843,23 @@ def load_request_model( ) self.request_editor.request_body_type_select.value = "form-body-editor" self.request_body_text_area.text = "" + self.request_editor.multipart_editor.replace_all_rows([]) + elif request_model.body.multipart_data: + self.request_editor.multipart_editor.replace_all_rows( + ( + (param.name, param.value) + for param in request_model.body.multipart_data + ), + (param.enabled for param in request_model.body.multipart_data), + ) + self.request_editor.request_body_type_select.value = "multipart-body-editor" + self.request_body_text_area.text = "" + self.request_editor.form_editor.replace_all_rows([]) else: self.request_body_text_area.text = "" self.request_editor.form_editor.replace_all_rows([]) self.request_editor.request_body_type_select.value = "no-body-label" + self.request_editor.multipart_editor.replace_all_rows([]) if overwrite_metadata: # Sometimes we don't wish to write request metadata, for example, if we're diff --git a/src/posting/collection.py b/src/posting/collection.py index 8413aaee2..03a7792ae 100644 --- a/src/posting/collection.py +++ b/src/posting/collection.py @@ -85,6 +85,12 @@ class QueryParam(BaseModel): enabled: bool = Field(default=True) +class MultipartItem(BaseModel): + name: str + value: str + enabled: bool = Field(default=True) + + class Cookie(BaseModel): name: str value: str @@ -110,6 +116,9 @@ class RequestBody(BaseModel): form_data: list[FormItem] | None = Field(default=None) """The form data of the request.""" + multipart_data: list[MultipartItem] | None = Field(default=None) + """The multipart form data of the request.""" + content_type: str | None = Field(default=None, init=False) """We may set an additional header if the content type is known.""" @@ -122,6 +131,12 @@ def to_httpx_args(self) -> dict[str, Any]: httpx_args["data"] = tuples_to_dict( [(item.name, item.value) for item in self.form_data if item.enabled] ) + if self.multipart_data: + # httpx must have at least one file + # Multipart Requests Without Attaching Files + # https://github.com/encode/httpx/issues/3396#issuecomment-2508142475 + # TODO: file upload + httpx_args["files"] = [(item.name, (None, item.value)) for item in self.multipart_data if item.enabled] return httpx_args @@ -215,6 +230,12 @@ def apply_template(self, variables: dict[str, Any]) -> None: item.name = template.substitute(variables) template = Template(item.value) item.value = template.substitute(variables) + if self.body.multipart_data: + for item in self.body.multipart_data: + template = Template(item.name) + item.name = template.substitute(variables) + template = Template(item.value) + item.value = template.substitute(variables) for header in self.headers: template = Template(header.name) @@ -336,6 +357,11 @@ def to_curl(self, extra_args: str = "") -> str: if item.enabled: parts.append(f"-d '{item.name}={item.value}'") + if self.body and self.body.multipart_data: + for item in self.body.multipart_data: + if item.enabled: + parts.append(f"-F '{item.name}={item.value}'") + if self.auth: if self.auth.type == "basic" and self.auth.basic: parts.append( diff --git a/src/posting/importing/curl.py b/src/posting/importing/curl.py index b9a5a030c..5d99e3cee 100644 --- a/src/posting/importing/curl.py +++ b/src/posting/importing/curl.py @@ -9,6 +9,7 @@ from posting.collection import ( Auth, FormItem, + MultipartItem, Header, HttpRequestMethod, Options, @@ -275,13 +276,24 @@ def to_request_model(self) -> RequestModel: body: RequestBody | None = None if self.data or self.form: if self.is_form_data: - # Use form data pairs from either -F or -d + # Use form data pairs from either -d form_data = self.data_pairs body = RequestBody( form_data=[ FormItem(name=name, value=value) for name, value in form_data ] ) + elif self.is_multipart_data: + # Use multipart form data from -F + body = RequestBody( + multipart_data=[ + MultipartItem( + name=name, + value=value, + ) + for name, value in self.form + ] + ) else: # Raw body content body = RequestBody(content=self.data) diff --git a/src/posting/importing/open_api.py b/src/posting/importing/open_api.py index 18102e6c9..c583c8472 100644 --- a/src/posting/importing/open_api.py +++ b/src/posting/importing/open_api.py @@ -27,6 +27,7 @@ Collection, ExternalDocs, FormItem, + MultipartItem, Header, QueryParam, RequestBody, @@ -386,7 +387,16 @@ def import_openapi_spec(spec_path: str | Path) -> Collection: ).items(): form_data.append(FormItem(name=prop_name, value="")) request.body = RequestBody(form_data=form_data) - + elif "multipart/form-data" in content: + multipart_data: list[MultipartItem] = [] + body = content["multipart/form-data"] + for prop_name, _prop_schema in ( + isinstance(body.media_type_schema, Schema) + and body.media_type_schema.properties + or {} + ).items(): + multipart_data.append(MultipartItem(name=prop_name, value="")) + request.body = RequestBody(multipart_data=multipart_data) if operation.summary and operation.tags: tag = operation.tags[0] tag_collection = tag_collections.get(tag) diff --git a/src/posting/widgets/request/multipart_editor.py b/src/posting/widgets/request/multipart_editor.py new file mode 100644 index 000000000..8aa13dfdd --- /dev/null +++ b/src/posting/widgets/request/multipart_editor.py @@ -0,0 +1,59 @@ +from typing import Iterable +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from posting.collection import MultipartItem +from posting.widgets.datatable import PostingDataTable +from posting.widgets.key_value import KeyValueEditor, KeyValueInput +from posting.widgets.variable_input import VariableInput + + +class MultipartTable(PostingDataTable): + BINDINGS = [ + Binding("backspace", action="remove_row", description="Remove row"), + Binding("space", action="toggle_row", description="Toggle row"), + ] + + def on_mount(self): + self.fixed_columns = 1 + self.show_header = False + self.cursor_type = "row" + self.zebra_stripes = True + self.row_disable = True + self.add_columns("Key", "Value") + + def to_model(self) -> list[MultipartItem]: + multipart_data: list[MultipartItem] = [] + for row_index in range(self.row_count): + row = self.get_row_at(row_index) + multipart_data.append( + MultipartItem( + name=row[0].plain if isinstance(row[0], Text) else row[0], + value=row[1].plain if isinstance(row[1], Text) else row[1], + enabled=self.is_row_enabled_at(row_index), + ) + ) + return multipart_data + + +class MultipartEditor(Vertical): + """An editor for multipart body data.""" + + def compose(self) -> ComposeResult: + yield KeyValueEditor( + MultipartTable(), + KeyValueInput( + VariableInput(placeholder="Key"), + VariableInput(placeholder="Value"), + ), + empty_message="There is no multipart data.", + ) + + def to_model(self) -> list[MultipartItem]: + return self.query_one(MultipartTable).to_model() + + def replace_all_rows( + self, rows: Iterable[Iterable[str]], enableStates: Iterable[bool] | None = None + ) -> None: + self.query_one(MultipartTable).replace_all_rows(rows, enableStates) diff --git a/src/posting/widgets/request/request_body.py b/src/posting/widgets/request/request_body.py index 7a6a639cb..27af87212 100644 --- a/src/posting/widgets/request/request_body.py +++ b/src/posting/widgets/request/request_body.py @@ -5,6 +5,7 @@ from posting.widgets.center_middle import CenterMiddle from posting.widgets.request.form_editor import FormEditor +from posting.widgets.request.multipart_editor import MultipartEditor from posting.widgets.select import PostingSelect from posting.widgets.text_area import PostingTextArea, TextAreaFooter, TextEditor @@ -24,6 +25,7 @@ def compose(self) -> ComposeResult: ("None", "no-body-label"), ("Raw (json, text, etc.)", "text-body-editor"), ("Form data", "form-body-editor"), + ("Multipart form data", "multipart-body-editor"), ], id="request-body-type-select", allow_blank=False, @@ -45,6 +47,9 @@ def compose(self) -> ComposeResult: yield FormEditor( id="form-body-editor", ) + yield MultipartEditor( + id="multipart-body-editor", + ) class RequestBodyTextArea(PostingTextArea): diff --git a/src/posting/widgets/request/request_editor.py b/src/posting/widgets/request/request_editor.py index 5ce9729b6..24742ccc2 100644 --- a/src/posting/widgets/request/request_editor.py +++ b/src/posting/widgets/request/request_editor.py @@ -6,6 +6,7 @@ from textual.widgets import ContentSwitcher, Select, TabPane from posting.collection import RequestBody from posting.widgets.request.form_editor import FormEditor +from posting.widgets.request.multipart_editor import MultipartEditor from posting.widgets.request.header_editor import HeaderEditor from posting.widgets.request.query_editor import QueryStringEditor @@ -76,6 +77,10 @@ def text_editor(self) -> TextEditor: def form_editor(self) -> FormEditor: return self.query_one("#form-body-editor", FormEditor) + @property + def multipart_editor(self) -> MultipartEditor: + return self.query_one("#multipart-body-editor", MultipartEditor) + @property def query_editor(self) -> QueryStringEditor: return self.query_one(QueryStringEditor) @@ -105,4 +110,12 @@ def to_request_model_args(self) -> dict[str, Any]: content_type="application/x-www-form-urlencoded", ) } + elif current == "multipart-body-editor": + return { + "body": RequestBody( + multipart_data=self.multipart_editor.to_model(), + # MUST NOT USE + # content_type="multipart/form-data", + ) + } return {}