Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/posting/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
(
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions src/posting/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""

Expand All @@ -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


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 13 additions & 1 deletion src/posting/importing/curl.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from posting.collection import (
Auth,
FormItem,
MultipartItem,
Header,
HttpRequestMethod,
Options,
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion src/posting/importing/open_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
Collection,
ExternalDocs,
FormItem,
MultipartItem,
Header,
QueryParam,
RequestBody,
Expand Down Expand Up @@ -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)
Expand Down
59 changes: 59 additions & 0 deletions src/posting/widgets/request/multipart_editor.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions src/posting/widgets/request/request_body.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -45,6 +47,9 @@ def compose(self) -> ComposeResult:
yield FormEditor(
id="form-body-editor",
)
yield MultipartEditor(
id="multipart-body-editor",
)


class RequestBodyTextArea(PostingTextArea):
Expand Down
13 changes: 13 additions & 0 deletions src/posting/widgets/request/request_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {}