diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8952f95c..c4ae84bd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6738,6 +6738,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.2.0" @@ -7568,6 +7574,7 @@ dependencies = [ "thiserror", "tokio", "tokio-stream", + "urlencoding", "uuid", "yaak_grpc", "yaak_models", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5fd41bf4..736b6854 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -58,6 +58,7 @@ tokio-stream = "0.1.15" uuid = "1.7.0" thiserror = "1.0.61" mime_guess = "2.0.5" +urlencoding = "2.1.3" [workspace.dependencies] yaak_models = { path = "yaak_models" } diff --git a/src-tauri/src/http_request.rs b/src-tauri/src/http_request.rs index 9abcb9c5..0d3635ef 100644 --- a/src-tauri/src/http_request.rs +++ b/src-tauri/src/http_request.rs @@ -23,7 +23,7 @@ use tauri::{Manager, Runtime, WebviewWindow}; use tokio::sync::oneshot; use tokio::sync::watch::Receiver; use yaak_models::models::{ - Cookie, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseHeader, + Cookie, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseHeader, HttpUrlParameter, }; use yaak_models::queries::{get_workspace, update_response_if_id, upsert_cookie_jar}; @@ -40,13 +40,8 @@ pub async fn send_http_request( .expect("Failed to get Workspace"); let cb = &*window.app_handle().state::(); let cb = cb.for_send(); - let rendered_request = render_http_request( - &request, - &workspace, - environment.as_ref(), - &cb, - ) - .await; + let rendered_request = + render_http_request(&request, &workspace, environment.as_ref(), &cb).await; let mut url_string = rendered_request.url; @@ -101,6 +96,23 @@ pub async fn send_http_request( let client = client_builder.build().expect("Failed to build client"); + // Render query parameters + let mut query_params = Vec::new(); + for p in rendered_request.url_parameters { + if !p.enabled || p.name.is_empty() { + continue; + } + + // Replace path parameters with values from URL parameters + let old_url_string = url_string.clone(); + url_string = replace_path_placeholder(&p, url_string.as_str()); + + // Treat as regular param if wasn't used as path param + if old_url_string == url_string { + query_params.push((p.name, p.value)); + } + } + let uri = match http::Uri::from_str(url_string.as_str()) { Ok(u) => u, Err(e) => { @@ -127,7 +139,7 @@ pub async fn send_http_request( let m = Method::from_bytes(rendered_request.method.to_uppercase().as_bytes()) .expect("Failed to create method"); - let mut request_builder = client.request(m, url); + let mut request_builder = client.request(m, url).query(&query_params); let mut headers = HeaderMap::new(); headers.insert(USER_AGENT, HeaderValue::from_static("yaak")); @@ -210,15 +222,6 @@ pub async fn send_http_request( } } - let mut query_params = Vec::new(); - for p in rendered_request.url_parameters { - if !p.enabled || p.name.is_empty() { - continue; - } - query_params.push((p.name, p.value)); - } - request_builder = request_builder.query(&query_params); - let request_body = rendered_request.body; if let Some(body_type) = &rendered_request.body_type { if request_body.contains_key("text") { @@ -489,3 +492,119 @@ fn get_str_h<'a>(v: &'a HashMap, key: &str) -> &'a str { Some(v) => v.as_str().unwrap_or_default(), } } + +fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String { + if !p.enabled { + return url.to_string(); + } + + let re = regex::Regex::new(format!("(/){}([/?#]|$)", p.name).as_str()).unwrap(); + let result = re + .replace_all(url, |cap: ®ex::Captures| { + format!( + "{}{}{}", + cap[1].to_string(), + urlencoding::encode(p.value.as_str()), + cap[2].to_string() + ) + }) + .into_owned(); + result +} + +#[cfg(test)] +mod tests { + use crate::http_request::replace_path_placeholder; + use yaak_models::models::HttpUrlParameter; + + #[test] + fn placeholder_middle() { + let p = HttpUrlParameter { + name: ":foo".into(), + value: "xxx".into(), + enabled: true, + }; + assert_eq!( + replace_path_placeholder(&p, "https://example.com/:foo/bar"), + "https://example.com/xxx/bar", + ); + } + + #[test] + fn placeholder_end() { + let p = HttpUrlParameter { + name: ":foo".into(), + value: "xxx".into(), + enabled: true, + }; + assert_eq!( + replace_path_placeholder(&p, "https://example.com/:foo"), + "https://example.com/xxx", + ); + } + + #[test] + fn placeholder_query() { + let p = HttpUrlParameter { + name: ":foo".into(), + value: "xxx".into(), + enabled: true, + }; + assert_eq!( + replace_path_placeholder(&p, "https://example.com/:foo?:foo"), + "https://example.com/xxx?:foo", + ); + } + + #[test] + fn placeholder_missing() { + let p = HttpUrlParameter { + enabled: true, + name: "".to_string(), + value: "".to_string(), + }; + assert_eq!( + replace_path_placeholder(&p, "https://example.com/:missing"), + "https://example.com/:missing", + ); + } + + #[test] + fn placeholder_disabled() { + let p = HttpUrlParameter { + enabled: false, + name: ":foo".to_string(), + value: "xxx".to_string(), + }; + assert_eq!( + replace_path_placeholder(&p, "https://example.com/:foo"), + "https://example.com/:foo", + ); + } + + #[test] + fn placeholder_prefix() { + let p = HttpUrlParameter { + name: ":foo".into(), + value: "xxx".into(), + enabled: true, + }; + assert_eq!( + replace_path_placeholder(&p, "https://example.com/:foooo"), + "https://example.com/:foooo", + ); + } + + #[test] + fn placeholder_encode() { + let p = HttpUrlParameter { + name: ":foo".into(), + value: "Hello World".into(), + enabled: true, + }; + assert_eq!( + replace_path_placeholder(&p, "https://example.com/:foo"), + "https://example.com/Hello%20World", + ); + } +} diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index ac5c83aa..5a00c5e3 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -344,6 +344,7 @@ export const RequestPane = memo(function RequestPane({ diff --git a/src-web/components/UrlParameterEditor.tsx b/src-web/components/UrlParameterEditor.tsx index cdfba446..5588d7d4 100644 --- a/src-web/components/UrlParameterEditor.tsx +++ b/src-web/components/UrlParameterEditor.tsx @@ -1,23 +1,49 @@ import type { HttpRequest } from '@yaakapp/api'; +import { useMemo } from 'react'; +import type { Pair } from './core/PairEditor'; import { PairOrBulkEditor } from './core/PairOrBulkEditor'; +import { VStack } from './core/Stacks'; type Props = { forceUpdateKey: string; urlParameters: HttpRequest['headers']; onChange: (headers: HttpRequest['urlParameters']) => void; + url: string; }; -export function UrlParametersEditor({ urlParameters, forceUpdateKey, onChange }: Props) { +export function UrlParametersEditor({ urlParameters, forceUpdateKey, onChange, url }: Props) { + const placeholderNames = Array.from(url.matchAll(/\/(:[^/]+)/g)).map((m) => m[1] ?? ''); + + const pairs = useMemo(() => { + const items: Pair[] = [...urlParameters]; + for (const name of placeholderNames) { + const index = items.findIndex((p) => p.name === name); + if (index >= 0) { + items[index]!.readOnlyName = true; + } else { + items.push({ + name, + value: '', + enabled: true, + readOnlyName: true, + }); + } + } + return items; + }, [placeholderNames, urlParameters]); + return ( - + + + ); } diff --git a/src-web/components/core/BulkPairEditor.tsx b/src-web/components/core/BulkPairEditor.tsx index 738b0bf8..3b428a74 100644 --- a/src-web/components/core/BulkPairEditor.tsx +++ b/src-web/components/core/BulkPairEditor.tsx @@ -42,12 +42,13 @@ export function BulkPairEditor({ ); } -function lineToPair(l: string): PairEditorProps['pairs'][0] { - const [name, ...values] = l.split(':'); +function lineToPair(line: string): PairEditorProps['pairs'][0] { + const [, name, value] = line.match(/^(:?[^:]+):\s+([^$]*)/) ?? []; + const pair: PairEditorProps['pairs'][0] = { enabled: true, name: (name ?? '').trim(), - value: values.join(':').trim(), + value: (value ?? '').trim(), }; return pair; } diff --git a/src-web/components/core/Editor/Editor.css b/src-web/components/core/Editor/Editor.css index 365ff6d6..280d8d3e 100644 --- a/src-web/components/core/Editor/Editor.css +++ b/src-web/components/core/Editor/Editor.css @@ -19,7 +19,8 @@ } .cm-line { - @apply w-full; /* Important! Ensure it spans the entire width */ + @apply w-full; + /* Important! Ensure it spans the entire width */ @apply w-full text-text pl-1 pr-1.5; } @@ -169,9 +170,14 @@ } } -.cm-wrapper.cm-readonly .cm-editor { - .cm-cursor { - @apply border-danger !important; +/* Cursor and mouse cursor for readonly mode */ +.cm-wrapper.cm-readonly { + .cm-editor .cm-cursor { + @apply hidden !important; + } + + &.cm-singleline .cm-line { + @apply cursor-default; } } diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index 4c14729e..41aabcb8 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -39,6 +39,7 @@ export { formatSdl } from 'format-graphql'; export interface EditorProps { id?: string; readOnly?: boolean; + disabled?: boolean; type?: 'text' | 'password'; className?: string; heightMode?: 'auto' | 'full'; diff --git a/src-web/components/core/Editor/extensions.ts b/src-web/components/core/Editor/extensions.ts index 6f215c02..0325ac4b 100644 --- a/src-web/components/core/Editor/extensions.ts +++ b/src-web/components/core/Editor/extensions.ts @@ -46,6 +46,10 @@ export const syntaxHighlightStyle = HighlightStyle.define([ color: 'var(--textSubtlest)', fontStyle: 'italic', }, + { + tag: [t.emphasis], + textDecoration: 'underline', + }, { tag: [t.paren, t.bracket, t.brace], color: 'var(--textSubtle)', diff --git a/src-web/components/core/Editor/pairs/pairs.grammar b/src-web/components/core/Editor/pairs/pairs.grammar index a776dedb..ec060c0c 100644 --- a/src-web/components/core/Editor/pairs/pairs.grammar +++ b/src-web/components/core/Editor/pairs/pairs.grammar @@ -1,8 +1,8 @@ -@top pairs { (Key? Sep Value)* } +@top pairs { (Key Sep Value "\n")* } @tokens { Sep { ":" } - Key { ![:]+ } + Key { ":"? ![:]+ } Value { ![\n]+ } } diff --git a/src-web/components/core/Editor/pairs/pairs.terms.ts b/src-web/components/core/Editor/pairs/pairs.terms.ts new file mode 100644 index 00000000..702786df --- /dev/null +++ b/src-web/components/core/Editor/pairs/pairs.terms.ts @@ -0,0 +1,6 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +export const + pairs = 1, + Key = 2, + Sep = 3, + Value = 4 diff --git a/src-web/components/core/Editor/pairs/pairs.ts b/src-web/components/core/Editor/pairs/pairs.ts index fca38f4f..098f4567 100644 --- a/src-web/components/core/Editor/pairs/pairs.ts +++ b/src-web/components/core/Editor/pairs/pairs.ts @@ -3,17 +3,17 @@ import {LRParser} from "@lezer/lr" import {highlight} from "./highlight" export const parser = LRParser.deserialize({ version: 14, - states: "!QQQOPOOOYOQO'#CaO_OPO'#CaQQOPOOOOOO,58{,58{OdOQO,58{OOOO-E6_-E6_OOOO1G.g1G.g", - stateData: "i~OQQORPO~OSSO~ORTO~OSVO~O", - goto: "]UPPPPPVQRORUR", + states: "zQQOPOOOVOQO'#CaQQOPOOO[OSO,58{OOOO-E6_-E6_OaOQO1G.gOOOO7+$R7+$R", + stateData: "f~OQPO~ORRO~OSTO~OVUO~O", + goto: "]UPPPPPVQQORSQ", nodeNames: "⚠ pairs Key Sep Value", - maxTerm: 6, + maxTerm: 7, propSources: [highlight], skippedNodes: [0], repeatNodeCount: 1, - tokenData: "#oRRVOYhYZ!UZ![h![!]#[!];'Sh;'S;=`#U<%lOhRoVQPSQOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!UQ!rSSQOY!mZ;'S!m;'S;=`#O<%lO!mQ#RP;=`<%l!mR#XP;=`<%lhR#cSRPSQOY!mZ;'S!m;'S;=`#O<%lO!m", - tokenizers: [0, 1], + tokenData: "$]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#vVRQSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOh", + tokenizers: [0, 1, 2], topRules: {"pairs":[0,1]}, - tokenPrec: 0 + tokenPrec: 0, + termNames: {"0":"⚠","1":"@top","2":"Key","3":"Sep","4":"Value","5":"(Key Sep Value \"\\n\")+","6":"␄","7":"\"\\n\""} }) - diff --git a/src-web/components/core/Editor/twig/twig.terms.ts b/src-web/components/core/Editor/twig/twig.terms.ts index cb65aeea..d6bda1a3 100644 --- a/src-web/components/core/Editor/twig/twig.terms.ts +++ b/src-web/components/core/Editor/twig/twig.terms.ts @@ -1,7 +1,8 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. -export const Template = 1, +export const + Template = 1, Tag = 2, + TagOpen = 3, TagContent = 4, - Open = 3, - Close = 5, - Text = 6; + TagClose = 5, + Text = 6 diff --git a/src-web/components/core/Editor/twig/twig.ts b/src-web/components/core/Editor/twig/twig.ts index f51bbc3b..04452432 100644 --- a/src-web/components/core/Editor/twig/twig.ts +++ b/src-web/components/core/Editor/twig/twig.ts @@ -16,4 +16,3 @@ export const parser = LRParser.deserialize({ topRules: {"Template":[0,1]}, tokenPrec: 0 }) - diff --git a/src-web/components/core/Editor/url/highlight.ts b/src-web/components/core/Editor/url/highlight.ts index 03fcd72c..e908323f 100644 --- a/src-web/components/core/Editor/url/highlight.ts +++ b/src-web/components/core/Editor/url/highlight.ts @@ -2,6 +2,8 @@ import { styleTags, tags as t } from '@lezer/highlight'; export const highlight = styleTags({ Protocol: t.comment, + Placeholder: t.emphasis, + // PathSegment: t.tagName, // Port: t.attributeName, // Host: t.variableName, // Path: t.bool, diff --git a/src-web/components/core/Editor/url/url.grammar b/src-web/components/core/Editor/url/url.grammar index aa2685cd..cb81cd73 100644 --- a/src-web/components/core/Editor/url/url.grammar +++ b/src-web/components/core/Editor/url/url.grammar @@ -1,18 +1,22 @@ -@top url { Protocol? Host Port? Path? Query? } +@top Program { url } -Query { - "?" queryPair ("&" queryPair)* -} +url { Protocol? Host Port? Path? Query? } + +Path { ("/" (Placeholder | PathSegment))+ } + +Query { "?" queryPair ("&" queryPair)* } @tokens { Protocol { $[a-zA-Z]+ "://" } - Path { ("/" $[a-zA-Z0-9\-_.]*)+ } - queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) } - Port { ":" $[0-9]+ } Host { $[a-zA-Z0-9-_.]+ } + Port { ":" $[0-9]+ } + Placeholder { ":" ![/?#]+ } + PathSegment { ![?#/]+ } + queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) } // Protocol/host overlaps, so give proto explicit precedence @precedence { Protocol, Host } + @precedence { Placeholder, PathSegment } } @external propSource highlight from "./highlight" diff --git a/src-web/components/core/Editor/url/url.terms.ts b/src-web/components/core/Editor/url/url.terms.ts index 39a59ed2..82a226dc 100644 --- a/src-web/components/core/Editor/url/url.terms.ts +++ b/src-web/components/core/Editor/url/url.terms.ts @@ -1,8 +1,10 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. export const - url = 1, + Program = 1, Protocol = 2, Host = 3, Port = 4, Path = 5, - Query = 6 + Placeholder = 6, + PathSegment = 7, + Query = 8 diff --git a/src-web/components/core/Editor/url/url.ts b/src-web/components/core/Editor/url/url.ts index 48df68ad..c1c3c351 100644 --- a/src-web/components/core/Editor/url/url.ts +++ b/src-web/components/core/Editor/url/url.ts @@ -3,16 +3,17 @@ import {LRParser} from "@lezer/lr" import {highlight} from "./highlight" export const parser = LRParser.deserialize({ version: 14, - states: "!jOQOPOOQYOPOOOTOPOOOeOQO'#CbQOOOOOQ`OPOOQ]OPOOOjOPO,58|OrOQO'#CcOwOPO1G.hOOOO,58},58}OOOO-E6a-E6a", - stateData: "!S~OQQORPO~OSUOTTOXRO~OYVO~OZWOWUa~OYYO~OZWOWUi~OQR~", - goto: "dWPPPPPPX^VSPTUQXVRZX", - nodeNames: "⚠ url Protocol Host Port Path Query", - maxTerm: 11, + states: "$UOQOPOOOYOPO'#ChOhOPO'#ChQOOOOOOmOQO'#CeOuOPO'#CaO!QOSO'#CdOOOO,59S,59SO!VOPO,59SO!_OPO,59SO!jOPO,59SOOOO,59P,59POOOO-E6c-E6cO!xOPO,59OOOOO1G.n1G.nO#QOPO1G.nO#YOPO1G.nO#eOSO'#CfO#jOPO1G.jOOOO7+$Y7+$YO#rOPO7+$YOOOO,59Q,59QOOOO-E6d-E6dOOOO< & { name: string; type?: 'text' | 'password'; @@ -68,6 +69,7 @@ export const Input = forwardRef(function Inp size = 'md', type = 'text', validate, + readOnly, ...props }: InputProps, ref, @@ -77,9 +79,10 @@ export const Input = forwardRef(function Inp const [focused, setFocused] = useState(false); const handleFocus = useCallback(() => { + if (readOnly) return; setFocused(true); onFocus?.(); - }, [onFocus]); + }, [onFocus, readOnly]); const handleBlur = useCallback(() => { setFocused(false); @@ -179,6 +182,7 @@ export const Input = forwardRef(function Inp className={editorClassName} onFocus={handleFocus} onBlur={handleBlur} + readOnly={readOnly} {...props} /> diff --git a/src-web/components/core/PairEditor.tsx b/src-web/components/core/PairEditor.tsx index ac7e30c2..91a29aff 100644 --- a/src-web/components/core/PairEditor.tsx +++ b/src-web/components/core/PairEditor.tsx @@ -41,6 +41,7 @@ export type Pair = { value: string; contentType?: string; isFile?: boolean; + readOnlyName?: boolean; }; type PairContainer = { @@ -254,8 +255,8 @@ function PairEditorRow({ valueAutocomplete, valueAutocompleteVariables, valuePlaceholder, - valueValidate, valueType, + valueValidate, }: PairEditorRowProps) { const { id } = pairContainer; const ref = useRef(null); @@ -374,6 +375,7 @@ function PairEditorRow({ ref={nameInputRef} hideLabel useTemplating + readOnly={pairContainer.pair.readOnlyName} size="sm" require={!isLast && !!pairContainer.pair.enabled && !!pairContainer.pair.value} validate={nameValidate} @@ -476,7 +478,14 @@ function PairEditorRow({ ) : (