summaryrefslogtreecommitdiff
path: root/internal/ui/format.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/ui/format.go')
-rw-r--r--internal/ui/format.go120
1 files changed, 120 insertions, 0 deletions
diff --git a/internal/ui/format.go b/internal/ui/format.go
new file mode 100644
index 0000000..8038d42
--- /dev/null
+++ b/internal/ui/format.go
@@ -0,0 +1,120 @@
+package ui
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/shiv/internal/store"
+)
+
+// PathOf extracts the path+query portion from a full URL string.
+func PathOf(rawURL string) string {
+ for _, prefix := range []string{"https://", "http://"} {
+ if strings.HasPrefix(rawURL, prefix) {
+ rest := rawURL[len(prefix):]
+ if slash := strings.Index(rest, "/"); slash >= 0 {
+ return rest[slash:]
+ }
+ return "/"
+ }
+ }
+ return rawURL
+}
+
+// PrettyJSON re-indents JSON, returning the original bytes on any error.
+func PrettyJSON(body []byte) []byte {
+ if len(body) == 0 {
+ return body
+ }
+ var buf bytes.Buffer
+ if err := json.Indent(&buf, body, "", " "); err != nil {
+ return body
+ }
+ return buf.Bytes()
+}
+
+// hostHeader returns the value to use for the Host header in a reconstructed
+// raw request. Default ports (80 for HTTP, 443 for HTTPS) are omitted per
+// RFC 7230 §5.4. Non-standard ports are always included.
+func hostHeader(tx store.Transaction) string {
+ if tx.Port == 0 || (tx.TLS && tx.Port == 443) || (!tx.TLS && tx.Port == 80) {
+ return tx.Host
+ }
+ return tx.Host + ":" + strconv.Itoa(tx.Port)
+}
+
+// FormatStoreRequest serialises a Transaction into a raw HTTP/1.1 request string
+// suitable for display in the UI or sending via Repeater/Intruder.
+//
+// The protocol is always written as HTTP/1.1 regardless of what was captured —
+func FormatStoreRequest(tx store.Transaction) string {
+ proto := tx.Proto
+ if proto == "" {
+ proto = "HTTP/1.1"
+ }
+
+ var b strings.Builder
+ fmt.Fprintf(&b, "%s %s %s\r\n", tx.Method, PathOf(tx.URL), proto)
+ fmt.Fprintf(&b, "Host: %s\r\n", hostHeader(tx))
+
+ keys := sortedKeys(tx.ReqHeaders)
+ for _, k := range keys {
+ // Skip Host — we already wrote a canonical one above.
+ if strings.EqualFold(k, "host") {
+ continue
+ }
+ for _, v := range tx.ReqHeaders[k] {
+ fmt.Fprintf(&b, "%s: %s\r\n", k, v)
+ }
+ }
+ b.WriteString("\r\n")
+ if len(tx.ReqBody) > 0 {
+ b.Write(PrettyJSON(tx.ReqBody))
+ }
+ return b.String()
+}
+
+// FormatStoreResponse serialises a Transaction into a raw HTTP response string
+// suitable for display in the UI.
+//
+// The store already holds decompressed body bytes — we must NOT decompress again.
+func FormatStoreResponse(tx store.Transaction) string {
+ var b strings.Builder
+ // Always render as HTTP/1.1; H2 pseudo-headers are not meaningful in raw text.
+ const proto = "HTTP/1.1"
+ fmt.Fprintf(&b, "%s %d\r\n", proto, tx.StatusCode)
+
+ keys := sortedKeys(tx.RespHeaders)
+ for _, k := range keys {
+ for _, v := range tx.RespHeaders[k] {
+ fmt.Fprintf(&b, "%s: %s\r\n", k, v)
+ }
+ }
+ b.WriteString("\r\n")
+
+ if len(tx.RespBody) > 0 {
+ // tx.RespBody is already decompressed by the proxy before storage.
+ // Do NOT call Decompress here — the body is plain bytes.
+ body := tx.RespBody
+ ct := tx.RespHeaders.Get("Content-Type")
+ if strings.Contains(ct, "application/json") {
+ b.Write(PrettyJSON(body))
+ } else {
+ b.Write(body)
+ }
+ }
+ return b.String()
+}
+
+func sortedKeys(h map[string][]string) []string {
+ keys := make([]string, 0, len(h))
+ for k := range h {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}