Repository: darrenburns/posting

Issue:
Add default body
After importing a OpenAPI 3 swagger file, the request are almost ready to be used accept for the body.

It would be nice to have the json body parsed from the spec and left the values empty.
That way we wouldn't have to open the spec to look for the values that should be passed.

The spec can also provide examples, which can be used as a default value.

```json
{
  "value": "",
  "optional_value": null,
  "value_with_default": "default"
}
```

Discussion:

Solution patch:
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 51fca80..c328318 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,3 +1,9 @@
+## Unreleased
+
+### Fixed
+
+- Exported curl command uses `-d` for form data now, rather than `-F`. The result is exported commands with form data will now use `application/x-www-form-urlencoded` instead of `multipart/form-data` (matching Posting's behaviour).
+
 ## 2.6.0 [29th March 2025]
 
 ### Added
diff --git a/docs/guide/external_tools.md b/docs/guide/external_tools.md
index fb9b248..6cc6726 100644
--- a/docs/guide/external_tools.md
+++ b/docs/guide/external_tools.md
@@ -53,13 +53,13 @@ your `config.yaml` file.
 For example:
 
 ```yaml title="config.yaml"
-pager_json: jq
+pager_json: fx
 ```
 
 Alternatively, you can set the `POSTING_PAGER_JSON` environment variable.
 
 ```bash
-export POSTING_PAGER_JSON=jq
+export POSTING_PAGER_JSON=fx
 ```
 
 If neither is set, Posting will try to use the default pager lookup rules discussed earlier.
diff --git a/src/posting/app.py b/src/posting/app.py
index 0a47552..0a667fa 100644
--- a/src/posting/app.py
+++ b/src/posting/app.py
@@ -740,7 +740,7 @@ class MainScreen(Screen[None]):
             path=open_request.path if open_request else None,
             description=self.request_metadata.description,
             method=self.selected_method,
-            url=self.url_input.value_including_protocol,
+            url=self.url_input.value.strip(),
             params=self.params_table.to_model(),
             headers=headers,
             options=request_options,
diff --git a/src/posting/collection.py b/src/posting/collection.py
index 8705f7f..8413aae 100644
--- a/src/posting/collection.py
+++ b/src/posting/collection.py
@@ -14,6 +14,7 @@ from posting.tuple_to_multidict import tuples_to_dict
 from posting.variables import SubstitutionError
 from posting.version import VERSION
 from posting.yaml import dump, load, Loader
+from posting.urls import ensure_protocol
 
 HttpRequestMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]
 VALID_HTTP_METHODS = get_args(HttpRequestMethod)
@@ -197,7 +198,7 @@ class RequestModel(BaseModel):
         """Apply the template to the request model."""
         try:
             template = Template(self.url)
-            self.url = template.substitute(variables)
+            self.url = ensure_protocol(template.substitute(variables))
 
             template = Template(self.description)
             self.description = template.substitute(variables)
@@ -333,7 +334,7 @@ class RequestModel(BaseModel):
         if self.body and self.body.form_data:
             for item in self.body.form_data:
                 if item.enabled:
-                    parts.append(f"-F '{item.name}={item.value}'")
+                    parts.append(f"-d '{item.name}={item.value}'")
 
         if self.auth:
             if self.auth.type == "basic" and self.auth.basic:
diff --git a/src/posting/importing/curl.py b/src/posting/importing/curl.py
index 14b9836..b9a5a03 100644
--- a/src/posting/importing/curl.py
+++ b/src/posting/importing/curl.py
@@ -4,6 +4,7 @@ from datetime import datetime
 import shlex
 from typing import cast
 from urllib.parse import ParseResult, parse_qsl, urlparse
+import itertools
 
 from posting.collection import (
     Auth,
@@ -48,9 +49,12 @@ class CurlImport:
         parser.add_argument(
             "-H", "--header", action="append", help="Pass custom header(s) to server"
         )
-        parser.add_argument("-d", "--data", help="HTTP POST data")
-        parser.add_argument("--data-raw", help="HTTP POST raw data")
+        parser.add_argument("-d", "--data", action="append", help="HTTP POST data")
+        parser.add_argument("--data-raw", action="append", help="HTTP POST raw data")
         parser.add_argument("--data-binary", help="HTTP POST binary data")
+        parser.add_argument(
+            "--data-urlencode", action="append", help="HTTP POST data with URL encoding"
+        )
         parser.add_argument(
             "-F", "--form", action="append", help="Specify multipart MIME data"
         )
@@ -83,7 +87,11 @@ class CurlImport:
             args.request
             or (
                 "POST"
-                if args.data or args.form or args.data_raw or args.data_binary
+                if args.data
+                or args.form
+                or args.data_raw
+                or args.data_binary
+                or args.data_urlencode
                 else "GET"
             ),
         )
@@ -104,22 +112,34 @@ class CurlImport:
 
         # Determine if the data is form data
         self.is_form_data = False
+        self.is_multipart_data = False
         content_type_header = next(
             (value for name, value in self.headers if name.lower() == "content-type"),
             "",
         ).lower()
 
         if args.form:
-            self.is_form_data = True
-        elif args.data or args.data_raw or args.data_binary:
+            self.is_multipart_data = True
+        elif args.data or args.data_raw or args.data_binary or args.data_urlencode:
             if "application/x-www-form-urlencoded" in content_type_header:
                 self.is_form_data = True
             elif not any("content-type" in h.lower() for h, _ in self.headers):
                 # Default content type for -d is application/x-www-form-urlencoded
                 self.is_form_data = True
 
-        # Store raw data
-        self.data = args.data or args.data_raw or args.data_binary
+        # Store raw data, simply join
+        datas = list(
+            filter(
+                None,
+                [
+                    args.data,
+                    args.data_raw,
+                    args.data_binary and [args.data_binary],
+                    args.data_urlencode,
+                ],
+            )
+        )
+        self.data = "&".join(itertools.chain(*datas)) if datas else None
 
         # Parse data into key-value pairs if it is form data
         if self.is_form_data and self.data:
@@ -256,7 +276,7 @@ class CurlImport:
         if self.data or self.form:
             if self.is_form_data:
                 # Use form data pairs from either -F or -d
-                form_data = self.form or self.data_pairs
+                form_data = self.data_pairs
                 body = RequestBody(
                     form_data=[
                         FormItem(name=name, value=value) for name, value in form_data
diff --git a/src/posting/importing/open_api.py b/src/posting/importing/open_api.py
index 279ccc1..18102e6 100644
--- a/src/posting/importing/open_api.py
+++ b/src/posting/importing/open_api.py
@@ -2,9 +2,19 @@ from __future__ import annotations
 import re
 from typing import Any
 from urllib.parse import urlparse
+import json
 
 import yaml
-from openapi_pydantic import OpenAPI, Reference, SecurityScheme
+from openapi_pydantic import (
+    OpenAPI,
+    Reference,
+    SecurityScheme,
+    Operation,
+    RequestBody as OpenAPIRequestBody,
+    Schema,
+    MediaType,
+    DataType,
+)
 from pathlib import Path
 
 
@@ -202,6 +212,74 @@ def create_env_file(
     return env_file
 
 
+def parse_schema_ref(ref: str, openapi: OpenAPI) -> Schema | None:
+    if not openapi.components or not openapi.components.schemas:
+        return None
+    if not ref.startswith("#/components/schemas/"):
+        return None
+    ref_name = ref[len("#/components/schemas/") :]
+    return openapi.components.schemas.get(ref_name)
+
+
+class JsonBodyGenerator:
+    def __init__(self, openapi: OpenAPI):
+        self.openapi = openapi
+        self.cache = {}
+        self.seen = set()
+
+    def generate_json(self, src: Reference | Schema | MediaType):
+        obj = self.generate(src)
+        if obj is None:
+            return "{}"
+        return json.dumps(obj, indent=2)
+
+    def generate(self, src: Reference | Schema | MediaType):
+        if isinstance(src, MediaType):
+            if src.media_type_schema is None:
+                return
+            return self.generate(src.media_type_schema)
+
+        if isinstance(src, Reference):
+            ref = src.ref
+            if ref in self.cache:
+                return self.cache[ref]
+
+            if ref in self.seen:
+                return
+
+            ref_schema = parse_schema_ref(ref, self.openapi)
+            if ref_schema is None:
+                return
+
+            self.seen.add(ref)
+            refobj = self._any_from_schema(ref_schema)
+            self.seen.remove(ref)
+
+            self.cache[ref] = refobj
+            return refobj
+        return self._any_from_schema(src)
+
+    def _any_from_schema(self, schema: Schema):
+        if schema.type == DataType.STRING:
+            return schema.default or ""
+        elif schema.type == DataType.NUMBER or schema.type == DataType.INTEGER:
+            return schema.default or 0
+        elif schema.type == DataType.BOOLEAN:
+            return schema.default or False
+        elif schema.type == DataType.ARRAY:
+            if schema.items is None:
+                return []
+            item = self.generate(schema.items)
+            if item is None:
+                return []
+            return [item]
+        elif schema.type == DataType.OBJECT:
+            obj = {}
+            for name, schema in (schema.properties or {}).items():
+                obj[name] = self.generate(schema)
+            return obj
+
+
 def import_openapi_spec(spec_path: str | Path) -> Collection:
     console = Console()
     console.print(f"Importing OpenAPI spec from {spec_path!r}.")
@@ -222,6 +300,7 @@ def import_openapi_spec(spec_path: str | Path) -> Collection:
         path=spec_path.parent,
         name=collection_name,
     )
+    tag_collections: map[str, Collection] = {}
 
     openapi = OpenAPI.model_validate(spec)
     security_schemes = openapi.components.securitySchemes or {}
@@ -243,67 +322,84 @@ def import_openapi_spec(spec_path: str | Path) -> Collection:
     readme = generate_readme(spec_path, info, external_docs, servers, env_files)
     main_collection.readme = readme
 
-    for path, path_item in spec.get("paths", {}).items():
-        for method, operation in path_item.items():
+    for path, path_item in (openapi.paths or {}).items():
+        for method in path_item.model_fields_set:
+            operation: Operation = getattr(path_item, method)
             method = method.upper()
             if method not in VALID_HTTP_METHODS:
                 continue
 
             request = RequestModel(
-                name=operation.get("summary", path.strip("/")),
-                description=operation.get("description", ""),
+                name=operation.summary or path.strip("/"),
+                description=operation.description or "",
                 method=method,
                 url=f"${{BASE_URL}}{path}",
             )
 
             # Add auth
-            for security in operation.get("security", []):
+            for security in operation.security or []:
                 for scheme_name, _scopes in security.items():
                     if scheme := security_schemes.get(scheme_name):
                         request.auth = security_scheme_to_auth(scheme_name, scheme)
                         break
 
             # Add query parameters
-            for param in operation.get("parameters", []):
-                if param["in"] == "query":
+            for param in operation.parameters or []:
+                if isinstance(param, Reference):
+                    continue
+                if param.param_in == "query":
                     request.params.append(
                         QueryParam(
-                            name=param["name"],
+                            name=param.name,
                             value="",  # Leave empty as it's just a template
-                            enabled=not param.get("deprecated", False),
+                            enabled=not param.deprecated,
                         )
                     )
 
             # Add headers
-            for param in operation.get("parameters", []):
-                if param["in"] == "header":
+            for param in operation.parameters or []:
+                if param.param_in == "header":
                     request.headers.append(
                         Header(
-                            name=param["name"],
+                            name=param.name,
                             value="",  # Leave empty as it's just a template
-                            enabled=not param.get("deprecated", False),
+                            enabled=not param.deprecated,
                         )
                     )
 
             # Add request body if present
-            if "requestBody" in operation:
-                content = operation["requestBody"].get("content", {})
+            if isinstance(operation.requestBody, OpenAPIRequestBody):
+                content = operation.requestBody.content
                 if "application/json" in content:
                     request.body = RequestBody(
-                        content="{}"
-                    )  # Empty JSON object as template
+                        content=JsonBodyGenerator(openapi).generate_json(
+                            content["application/json"]
+                        )
+                    )
                 elif "application/x-www-form-urlencoded" in content:
                     form_data: list[FormItem] = []
+                    body = content["application/x-www-form-urlencoded"]
                     for prop_name, _prop_schema in (
-                        content["application/x-www-form-urlencoded"]
-                        .get("schema", {})
-                        .get("properties", {})
-                        .items()
-                    ):
+                        isinstance(body.media_type_schema, Schema)
+                        and body.media_type_schema.properties
+                        or {}
+                    ).items():
                         form_data.append(FormItem(name=prop_name, value=""))
                     request.body = RequestBody(form_data=form_data)
 
-            main_collection.requests.append(request)
+            if operation.summary and operation.tags:
+                tag = operation.tags[0]
+                tag_collection = tag_collections.get(tag)
+                if tag_collection is None:
+                    tag_collection = Collection(
+                        path=spec_path.parent,
+                        name=tag,
+                    )
+                    tag_collections[tag] = tag_collection
+                    main_collection.children.append(tag_collection)
+                tag_collection.requests.append(request)
+            else:
+                main_collection.requests.append(request)
 
     console.print(f"Imported {len(main_collection.requests)} requests.")
     return main_collection
diff --git a/src/posting/widgets/request/url_bar.py b/src/posting/widgets/request/url_bar.py
index 507cdf2..a55478f 100644
--- a/src/posting/widgets/request/url_bar.py
+++ b/src/posting/widgets/request/url_bar.py
@@ -108,10 +108,6 @@ It's recommended you create a new request before pasting a curl command, to avoi
         event.prevent_default()
         self.post_message(CurlMessage(event.text))
 
-    @property
-    def value_including_protocol(self) -> str:
-        return ensure_protocol(self.value.strip())
-
 
 class SendRequestButton(Button, can_focus=False):
     """


Test patch:
diff --git a/tests/test_curl_export.py b/tests/test_curl_export.py
index e7e9fe5..d4ad0b4 100644
--- a/tests/test_curl_export.py
+++ b/tests/test_curl_export.py
@@ -167,7 +167,7 @@ def test_request_with_form_data():
         ),
     )
 
-    expected = "curl \\\n  -X POST \\\n  -F 'name=John Doe' \\\n  -F 'email=john@example.com' \\\n  'https://example.com/api/form'"
+    expected = "curl \\\n  -X POST \\\n  -d 'name=John Doe' \\\n  -d 'email=john@example.com' \\\n  'https://example.com/api/form'"
     assert request.to_curl() == expected
 
 
diff --git a/tests/test_curl_import.py b/tests/test_curl_import.py
index 29c5539..a37691d 100644
--- a/tests/test_curl_import.py
+++ b/tests/test_curl_import.py
@@ -47,14 +47,14 @@ def test_post_with_json_data():
 
 
 def test_post_with_form_option():
-    """Test POST request with form data using -F."""
+    """Test POST request with form data using -d."""
     curl_command = (
-        "curl -X POST -F 'field1=value1' -F 'field2=value2' http://example.com"
+        "curl -X POST -d 'field1=value1' -d 'field2=value2' http://example.com"
     )
     curl_import = CurlImport(curl_command)
     assert curl_import.method == "POST"
     assert curl_import.url == "http://example.com"
-    assert curl_import.form == [("field1", "value1"), ("field2", "value2")]
+    assert curl_import.data_pairs == [("field1", "value1"), ("field2", "value2")]
     assert curl_import.is_form_data is True
 
 
@@ -63,15 +63,9 @@ def test_multiple_data_options():
     curl_command = "curl -d 'key1=value1' -d 'key2=value2' http://example.com"
     curl_import = CurlImport(curl_command)
     # Depending on implementation, data might be concatenated or last one used
-    assert (
-        curl_import.data == "key1=value1key2=value2"
-        or curl_import.data == "key2=value2"
-    )
+    assert curl_import.data == "key1=value1&key2=value2"
     assert curl_import.is_form_data is True
-    assert curl_import.data_pairs in (
-        [("key1", "value1"), ("key2", "value2")],
-        [("key2", "value2")],
-    )
+    assert curl_import.data_pairs == [("key1", "value1"), ("key2", "value2")]
 
 
 def test_curl_with_user_and_password():