mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-24 12:24:01 +02:00
Respect OpenAPI parameter serialization rules (#588)
This commit is contained in:
@@ -78,8 +78,19 @@ impl SendableHttpRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn insert_header(&mut self, header: (String, String)) {
|
pub fn insert_header(&mut self, header: (String, String)) {
|
||||||
|
if header.0.eq_ignore_ascii_case("cookie") {
|
||||||
|
if let Some(existing) =
|
||||||
|
self.headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case("cookie"))
|
||||||
|
{
|
||||||
|
existing.1 = format!("{}; {}", existing.1, header.1);
|
||||||
|
} else {
|
||||||
|
self.headers.push(header);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(existing) =
|
if let Some(existing) =
|
||||||
self.headers.iter_mut().find(|h| h.0.to_lowercase() == header.0.to_lowercase())
|
self.headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case(&header.0))
|
||||||
{
|
{
|
||||||
existing.1 = header.1;
|
existing.1 = header.1;
|
||||||
} else {
|
} else {
|
||||||
@@ -494,7 +505,76 @@ mod tests {
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use yaak_models::models::{HttpRequest, HttpUrlParameter};
|
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sendable_request_preserves_independent_cookie_enabled_states() {
|
||||||
|
let request = HttpRequest {
|
||||||
|
url: "https://example.com/api".to_string(),
|
||||||
|
headers: vec![
|
||||||
|
HttpRequestHeader {
|
||||||
|
enabled: true,
|
||||||
|
name: "Cookie".to_string(),
|
||||||
|
value: "session=abc".to_string(),
|
||||||
|
id: None,
|
||||||
|
},
|
||||||
|
HttpRequestHeader {
|
||||||
|
enabled: false,
|
||||||
|
name: "Cookie".to_string(),
|
||||||
|
value: "debug=verbose".to_string(),
|
||||||
|
id: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let sendable =
|
||||||
|
SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(sendable.headers, vec![("Cookie".to_string(), "session=abc".to_string())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_insert_header_appends_authentication_cookie() {
|
||||||
|
let mut request = SendableHttpRequest {
|
||||||
|
headers: vec![
|
||||||
|
("Cookie".to_string(), "session=abc".to_string()),
|
||||||
|
("Cookie".to_string(), "theme=dark".to_string()),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
request.insert_header(("cookie".to_string(), "api_key=secret".to_string()));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request.headers,
|
||||||
|
vec![
|
||||||
|
("Cookie".to_string(), "session=abc; api_key=secret".to_string()),
|
||||||
|
("Cookie".to_string(), "theme=dark".to_string()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sendable_request_preserves_serialized_path_delimiters() {
|
||||||
|
let request = HttpRequest {
|
||||||
|
url: "https://example.com/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2"
|
||||||
|
.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let sendable =
|
||||||
|
SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sendable.url,
|
||||||
|
"https://example.com/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_url_no_params() {
|
fn test_build_url_no_params() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::conflict_free_name;
|
use super::{conflict_free_name, merge_headers};
|
||||||
use crate::client_db::ClientDb;
|
use crate::client_db::ClientDb;
|
||||||
use crate::connection_or_tx::ConnectionOrTx;
|
use crate::connection_or_tx::ConnectionOrTx;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
@@ -144,9 +144,7 @@ impl<'a> ClientDb<'a> {
|
|||||||
headers.append(&mut workspace_headers);
|
headers.append(&mut workspace_headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.append(&mut folder.headers.clone());
|
Ok(merge_headers(headers, folder.headers.clone()))
|
||||||
|
|
||||||
Ok(headers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_settings_for_folder(
|
pub fn resolve_settings_for_folder(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::{conflict_free_name, dedupe_headers};
|
use super::{conflict_free_name, merge_headers};
|
||||||
use crate::client_db::ClientDb;
|
use crate::client_db::ClientDb;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
@@ -110,9 +110,7 @@ impl<'a> ClientDb<'a> {
|
|||||||
metadata.append(&mut workspace_metadata);
|
metadata.append(&mut workspace_metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata.append(&mut grpc_request.metadata.clone());
|
Ok(merge_headers(metadata, grpc_request.metadata.clone()))
|
||||||
|
|
||||||
Ok(dedupe_headers(metadata))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_settings_for_grpc_request(
|
pub fn resolve_settings_for_grpc_request(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::{conflict_free_name, dedupe_headers};
|
use super::{conflict_free_name, merge_headers};
|
||||||
use crate::client_db::ClientDb;
|
use crate::client_db::ClientDb;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
@@ -96,9 +96,7 @@ impl<'a> ClientDb<'a> {
|
|||||||
headers.append(&mut workspace_headers);
|
headers.append(&mut workspace_headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.append(&mut http_request.headers.clone());
|
Ok(merge_headers(headers, http_request.headers.clone()))
|
||||||
|
|
||||||
Ok(dedupe_headers(headers))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_settings_for_http_request(
|
pub fn resolve_settings_for_http_request(
|
||||||
@@ -172,3 +170,44 @@ impl<'a> ClientDb<'a> {
|
|||||||
Ok(children)
|
Ok(children)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::init_in_memory;
|
||||||
|
use crate::models::{HttpRequest, HttpRequestHeader};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_resolution_preserves_duplicate_request_headers() {
|
||||||
|
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||||
|
let db = query_manager.connect();
|
||||||
|
let workspace = db.list_workspaces().expect("Failed to list workspaces").remove(0);
|
||||||
|
let request = HttpRequest {
|
||||||
|
workspace_id: workspace.id,
|
||||||
|
headers: vec![
|
||||||
|
HttpRequestHeader {
|
||||||
|
name: "Cookie".to_string(),
|
||||||
|
value: "required=1".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
HttpRequestHeader {
|
||||||
|
enabled: false,
|
||||||
|
name: "Cookie".to_string(),
|
||||||
|
value: "optional=1".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved = db.resolve_headers_for_http_request(&request).expect("Failed to resolve");
|
||||||
|
let cookies = resolved
|
||||||
|
.iter()
|
||||||
|
.filter(|header| header.name.eq_ignore_ascii_case("cookie"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(cookies.len(), 2);
|
||||||
|
assert_eq!(cookies[0].value, "required=1");
|
||||||
|
assert_eq!(cookies[1].value, "optional=1");
|
||||||
|
assert!(!cookies[1].enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,21 +28,59 @@ pub(crate) use duplicate_name::conflict_free_name;
|
|||||||
const MAX_HISTORY_ITEMS: usize = 20;
|
const MAX_HISTORY_ITEMS: usize = 20;
|
||||||
|
|
||||||
use crate::models::HttpRequestHeader;
|
use crate::models::HttpRequestHeader;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
/// Deduplicate headers by name (case-insensitive), keeping the latest (most specific) value.
|
/// Merge a more-specific header layer over its parent. Names in the child replace
|
||||||
/// Preserves the order of first occurrence for each header name.
|
/// inherited values case-insensitively, while duplicates declared together in
|
||||||
pub(crate) fn dedupe_headers(headers: Vec<HttpRequestHeader>) -> Vec<HttpRequestHeader> {
|
/// either layer remain independent entries.
|
||||||
let mut index_by_name: HashMap<String, usize> = HashMap::new();
|
pub(crate) fn merge_headers(
|
||||||
let mut deduped: Vec<HttpRequestHeader> = Vec::new();
|
mut parent: Vec<HttpRequestHeader>,
|
||||||
for header in headers {
|
child: Vec<HttpRequestHeader>,
|
||||||
let key = header.name.to_lowercase();
|
) -> Vec<HttpRequestHeader> {
|
||||||
if let Some(&idx) = index_by_name.get(&key) {
|
let child_names = child.iter().map(|header| header.name.to_lowercase()).collect::<HashSet<_>>();
|
||||||
deduped[idx] = header;
|
parent.retain(|header| !child_names.contains(&header.name.to_lowercase()));
|
||||||
} else {
|
parent.extend(child);
|
||||||
index_by_name.insert(key, deduped.len());
|
parent
|
||||||
deduped.push(header);
|
}
|
||||||
}
|
|
||||||
}
|
#[cfg(test)]
|
||||||
deduped
|
mod tests {
|
||||||
|
use super::merge_headers;
|
||||||
|
use crate::models::HttpRequestHeader;
|
||||||
|
|
||||||
|
fn header(name: &str, value: &str) -> HttpRequestHeader {
|
||||||
|
HttpRequestHeader { name: name.to_string(), value: value.to_string(), ..Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_duplicate_headers_declared_in_one_layer() {
|
||||||
|
let merged = merge_headers(
|
||||||
|
vec![header("Cookie", "inherited=1")],
|
||||||
|
vec![
|
||||||
|
header("Cookie", "required=1"),
|
||||||
|
header("cookie", "optional=1"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
merged.iter().map(|header| header.value.as_str()).collect::<Vec<_>>(),
|
||||||
|
vec!["required=1", "optional=1"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn child_names_override_parent_names_without_affecting_other_headers() {
|
||||||
|
let merged = merge_headers(
|
||||||
|
vec![header("Accept", "*/*"), header("X-Parent", "kept")],
|
||||||
|
vec![header("accept", "application/json")],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
merged
|
||||||
|
.iter()
|
||||||
|
.map(|header| (header.name.as_str(), header.value.as_str()))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![("X-Parent", "kept"), ("accept", "application/json")],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::{conflict_free_name, dedupe_headers};
|
use super::{conflict_free_name, merge_headers};
|
||||||
use crate::client_db::ClientDb;
|
use crate::client_db::ClientDb;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
@@ -103,13 +103,9 @@ impl<'a> ClientDb<'a> {
|
|||||||
&self,
|
&self,
|
||||||
websocket_request: &WebsocketRequest,
|
websocket_request: &WebsocketRequest,
|
||||||
) -> Result<Vec<HttpRequestHeader>> {
|
) -> Result<Vec<HttpRequestHeader>> {
|
||||||
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
|
|
||||||
|
|
||||||
// Resolved headers should be from furthest to closest ancestor, to override logically.
|
// Resolved headers should be from furthest to closest ancestor, to override logically.
|
||||||
let mut headers = Vec::new();
|
let mut headers = Vec::new();
|
||||||
|
|
||||||
headers.append(&mut workspace.headers.clone());
|
|
||||||
|
|
||||||
if let Some(folder_id) = websocket_request.folder_id.clone() {
|
if let Some(folder_id) = websocket_request.folder_id.clone() {
|
||||||
let parent_folder = self.get_folder(&folder_id)?;
|
let parent_folder = self.get_folder(&folder_id)?;
|
||||||
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
|
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
|
||||||
@@ -120,9 +116,7 @@ impl<'a> ClientDb<'a> {
|
|||||||
headers.append(&mut workspace_headers);
|
headers.append(&mut workspace_headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.append(&mut websocket_request.headers.clone());
|
Ok(merge_headers(headers, websocket_request.headers.clone()))
|
||||||
|
|
||||||
Ok(dedupe_headers(headers))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_settings_for_websocket_request(
|
pub fn resolve_settings_for_websocket_request(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::merge_headers;
|
||||||
use crate::blob_manager::BlobManager;
|
use crate::blob_manager::BlobManager;
|
||||||
use crate::client_db::ClientDb;
|
use crate::client_db::ClientDb;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
@@ -144,9 +145,7 @@ impl<'a> ClientDb<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> {
|
pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> {
|
||||||
let mut headers = default_headers();
|
merge_headers(default_headers(), workspace.headers.clone())
|
||||||
headers.extend(workspace.headers.clone());
|
|
||||||
headers
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_settings_for_workspace(
|
pub fn resolve_settings_for_workspace(
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ function importOperation({
|
|||||||
useDynamicServerUrls,
|
useDynamicServerUrls,
|
||||||
});
|
});
|
||||||
const urlParameters = [
|
const urlParameters = [
|
||||||
...importUrlParameters({ importState, parameters }),
|
...importUrlParameters({ importState, parameters, path }),
|
||||||
...authentication.urlParameters,
|
...authentication.urlParameters,
|
||||||
];
|
];
|
||||||
const headers = mergeHeaders(
|
const headers = mergeHeaders(
|
||||||
@@ -336,6 +336,8 @@ function importOperation({
|
|||||||
url: buildOperationUrl(
|
url: buildOperationUrl(
|
||||||
operationBaseUrl({ operation, pathItem, requestBaseUrl, serverOverrides }),
|
operationBaseUrl({ operation, pathItem, requestBaseUrl, serverOverrides }),
|
||||||
path,
|
path,
|
||||||
|
parameters,
|
||||||
|
importState,
|
||||||
),
|
),
|
||||||
urlParameters,
|
urlParameters,
|
||||||
headers,
|
headers,
|
||||||
@@ -645,8 +647,58 @@ function findOrCreateFolderId({
|
|||||||
return folder.id;
|
return folder.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOperationUrl(baseUrl: string, path: string): string {
|
function buildOperationUrl(
|
||||||
return joinUrlParts(baseUrl, path.replaceAll(/{([^}/]+)}/g, ":$1"));
|
baseUrl: string,
|
||||||
|
path: string,
|
||||||
|
parameters: unknown[],
|
||||||
|
importState: ImportState,
|
||||||
|
): string {
|
||||||
|
let serializedPath = path;
|
||||||
|
for (const rawParameter of parameters) {
|
||||||
|
const parameter = importState.resolve(rawParameter);
|
||||||
|
if (!isRecord(parameter) || !shouldInlinePathParameter(parameter, importState, path)) continue;
|
||||||
|
|
||||||
|
const name = stringAt(parameter, "name") ?? "";
|
||||||
|
if (name.length === 0) continue;
|
||||||
|
const value = parameterExampleValue(parameter, importState);
|
||||||
|
serializedPath = serializedPath.replaceAll(
|
||||||
|
`{${name}}`,
|
||||||
|
isRecord(parameter.content)
|
||||||
|
? encodePathComponent(serializeContentParameter(parameter, importState))
|
||||||
|
: serializePathParameter(name, value, parameter, encodePathComponent),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return joinUrlParts(baseUrl, serializedPath.replaceAll(/{([^}/]+)}/g, ":$1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldInlinePathParameter(
|
||||||
|
parameter: UnknownRecord,
|
||||||
|
importState: ImportState,
|
||||||
|
path: string,
|
||||||
|
): boolean {
|
||||||
|
if (stringAt(parameter, "in") !== "path") return false;
|
||||||
|
const name = stringAt(parameter, "name") ?? "";
|
||||||
|
const template = `{${name}}`;
|
||||||
|
const matchingSegments = path.split("/").filter((segment) => segment.includes(template));
|
||||||
|
if (matchingSegments.length === 0 || matchingSegments.some((segment) => segment !== template)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isRecord(parameter.content)) return false;
|
||||||
|
const value = parameterExampleValue(parameter, importState);
|
||||||
|
const style = stringAt(parameter, "style");
|
||||||
|
return (
|
||||||
|
style === "label" ||
|
||||||
|
style === "matrix" ||
|
||||||
|
Array.isArray(value) ||
|
||||||
|
isRecord(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodePathComponent(value: unknown): string {
|
||||||
|
return encodeURIComponent(stringifyExampleValue(value)).replace(
|
||||||
|
/[!'()*]/g,
|
||||||
|
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function importBaseUrl(spec: UnknownRecord): string {
|
function importBaseUrl(spec: UnknownRecord): string {
|
||||||
@@ -733,25 +785,82 @@ function trimTrailingSlashes(value: string): string {
|
|||||||
function importUrlParameters({
|
function importUrlParameters({
|
||||||
importState,
|
importState,
|
||||||
parameters,
|
parameters,
|
||||||
|
path,
|
||||||
}: {
|
}: {
|
||||||
importState: ImportState;
|
importState: ImportState;
|
||||||
parameters: unknown[];
|
parameters: unknown[];
|
||||||
|
path: string;
|
||||||
}): HttpUrlParameter[] {
|
}): HttpUrlParameter[] {
|
||||||
return parameters
|
return parameters
|
||||||
.map((p) => importState.resolve(p))
|
.map((p) => importState.resolve(p))
|
||||||
.filter(isRecord)
|
.filter(isRecord)
|
||||||
.filter((p) => stringAt(p, "in") === "query" || stringAt(p, "in") === "path")
|
.filter((p) => stringAt(p, "in") === "query" || stringAt(p, "in") === "path")
|
||||||
.map((p) => ({
|
.flatMap((p) => serializeUrlParameter(p, importState, path))
|
||||||
enabled: p.required === true,
|
|
||||||
name:
|
|
||||||
stringAt(p, "in") === "path"
|
|
||||||
? `:${stringAt(p, "name") ?? ""}`
|
|
||||||
: (stringAt(p, "name") ?? ""),
|
|
||||||
value: parameterExample(p, importState),
|
|
||||||
}))
|
|
||||||
.filter(({ name }) => name.length > 0);
|
.filter(({ name }) => name.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeUrlParameter(
|
||||||
|
parameter: UnknownRecord,
|
||||||
|
importState: ImportState,
|
||||||
|
path: string,
|
||||||
|
): HttpUrlParameter[] {
|
||||||
|
const name = stringAt(parameter, "name") ?? "";
|
||||||
|
const location = stringAt(parameter, "in");
|
||||||
|
const enabled = parameter.required === true;
|
||||||
|
const value = parameterExampleValue(parameter, importState);
|
||||||
|
if (isRecord(parameter.content)) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
enabled,
|
||||||
|
name: location === "path" ? `:${name}` : name,
|
||||||
|
value: serializeContentParameter(parameter, importState),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (location === "path") {
|
||||||
|
if (shouldInlinePathParameter(parameter, importState, path)) return [];
|
||||||
|
return [{ enabled, name: `:${name}`, value: serializePathParameter(name, value, parameter) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(value)) {
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
const style = stringAt(parameter, "style") ?? "form";
|
||||||
|
const explode = parameter.explode !== false;
|
||||||
|
if (style === "deepObject") {
|
||||||
|
return entries.map(([key, entryValue]) => ({
|
||||||
|
enabled,
|
||||||
|
name: `${name}[${key}]`,
|
||||||
|
value: stringifyExampleValue(entryValue),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (style === "form" && explode) {
|
||||||
|
return entries.map(([key, entryValue]) => ({
|
||||||
|
enabled,
|
||||||
|
name: key,
|
||||||
|
value: stringifyExampleValue(entryValue),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const separator = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ",";
|
||||||
|
return [{ enabled, name, value: entries.flat().map(stringifyExampleValue).join(separator) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const style = stringAt(parameter, "style") ?? "form";
|
||||||
|
const explode = parameter.explode !== false;
|
||||||
|
if (style === "form" && explode) {
|
||||||
|
return value.map((entryValue) => ({
|
||||||
|
enabled,
|
||||||
|
name,
|
||||||
|
value: stringifyExampleValue(entryValue),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const separator = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ",";
|
||||||
|
return [{ enabled, name, value: value.map(stringifyExampleValue).join(separator) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ enabled, name, value: stringifyExampleValue(value) }];
|
||||||
|
}
|
||||||
|
|
||||||
function importHeaderParameters({
|
function importHeaderParameters({
|
||||||
importState,
|
importState,
|
||||||
parameters,
|
parameters,
|
||||||
@@ -763,18 +872,138 @@ function importHeaderParameters({
|
|||||||
.map((p) => importState.resolve(p))
|
.map((p) => importState.resolve(p))
|
||||||
.filter(isRecord)
|
.filter(isRecord)
|
||||||
.filter((p) => stringAt(p, "in") === "header")
|
.filter((p) => stringAt(p, "in") === "header")
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
!["accept", "authorization", "content-type"].includes(
|
||||||
|
(stringAt(p, "name") ?? "").toLowerCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
enabled: p.required === true,
|
enabled: p.required === true,
|
||||||
name: stringAt(p, "name") ?? "",
|
name: stringAt(p, "name") ?? "",
|
||||||
value: parameterExample(p, importState),
|
value: serializeParameterValue(p, importState),
|
||||||
}))
|
}))
|
||||||
.filter(({ name }) => name.length > 0);
|
.filter(({ name }) => name.length > 0)
|
||||||
|
.concat(importCookieHeader(parameters, importState));
|
||||||
|
}
|
||||||
|
|
||||||
|
function importCookieHeader(parameters: unknown[], importState: ImportState): HttpRequestHeader[] {
|
||||||
|
return parameters
|
||||||
|
.map((p) => importState.resolve(p))
|
||||||
|
.filter(isRecord)
|
||||||
|
.filter((p) => stringAt(p, "in") === "cookie")
|
||||||
|
.map((p) => ({
|
||||||
|
enabled: p.required === true,
|
||||||
|
name: "Cookie",
|
||||||
|
value: serializeCookieParameter(p, importState),
|
||||||
|
}))
|
||||||
|
.filter(({ value }) => value.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeCookieParameter(parameter: UnknownRecord, importState: ImportState): string {
|
||||||
|
const name = stringAt(parameter, "name") ?? "";
|
||||||
|
if (name.length === 0) return "";
|
||||||
|
if (isRecord(parameter.content)) {
|
||||||
|
return `${name}=${serializeContentParameter(parameter, importState)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = parameterExampleValue(parameter, importState);
|
||||||
|
const explode = parameter.explode !== false;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return explode
|
||||||
|
? value.map((entryValue) => `${name}=${stringifyExampleValue(entryValue)}`).join("&")
|
||||||
|
: `${name}=${value.map(stringifyExampleValue).join(",")}`;
|
||||||
|
}
|
||||||
|
if (isRecord(value)) {
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
return explode
|
||||||
|
? entries.map(([key, entryValue]) => `${key}=${stringifyExampleValue(entryValue)}`).join("&")
|
||||||
|
: `${name}=${entries.flat().map(stringifyExampleValue).join(",")}`;
|
||||||
|
}
|
||||||
|
return `${name}=${stringifyExampleValue(value)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeParameterValue(parameter: UnknownRecord, importState: ImportState): string {
|
||||||
|
if (isRecord(parameter.content)) return serializeContentParameter(parameter, importState);
|
||||||
|
return serializeSimpleParameter(parameterExampleValue(parameter, importState), parameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeContentParameter(parameter: UnknownRecord, importState: ImportState): string {
|
||||||
|
const [contentType, rawMediaType] = Object.entries(toRecord(parameter.content))[0] ?? [];
|
||||||
|
const value = mediaTypeExample(toRecord(rawMediaType), importState);
|
||||||
|
return contentType?.toLowerCase().includes("json")
|
||||||
|
? (JSON.stringify(value) ?? "")
|
||||||
|
: stringifyExampleValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializePathParameter(
|
||||||
|
name: string,
|
||||||
|
value: unknown,
|
||||||
|
parameter: UnknownRecord,
|
||||||
|
serializeValue: (value: unknown) => string = stringifyExampleValue,
|
||||||
|
): string {
|
||||||
|
const style = stringAt(parameter, "style") ?? "simple";
|
||||||
|
const explode = parameter.explode === true;
|
||||||
|
const values = Array.isArray(value)
|
||||||
|
? value.map(serializeValue)
|
||||||
|
: isRecord(value)
|
||||||
|
? Object.entries(value).flatMap(([key, entryValue]) => [
|
||||||
|
serializeValue(key),
|
||||||
|
serializeValue(entryValue),
|
||||||
|
])
|
||||||
|
: [serializeValue(value)];
|
||||||
|
|
||||||
|
if (style === "label") {
|
||||||
|
if (explode && isRecord(value)) {
|
||||||
|
return `.${Object.entries(value)
|
||||||
|
.map(([key, entryValue]) => `${serializeValue(key)}=${serializeValue(entryValue)}`)
|
||||||
|
.join(".")}`;
|
||||||
|
}
|
||||||
|
return `.${values.join(explode ? "." : ",")}`;
|
||||||
|
}
|
||||||
|
if (style === "matrix") {
|
||||||
|
if (explode && Array.isArray(value)) {
|
||||||
|
return value.map((entryValue) => `;${name}=${serializeValue(entryValue)}`).join("");
|
||||||
|
}
|
||||||
|
if (explode && isRecord(value)) {
|
||||||
|
return Object.entries(value)
|
||||||
|
.map(([key, entryValue]) => `;${serializeValue(key)}=${serializeValue(entryValue)}`)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
return `;${name}=${values.join(",")}`;
|
||||||
|
}
|
||||||
|
return serializeSimpleParameter(value, parameter, serializeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeSimpleParameter(
|
||||||
|
value: unknown,
|
||||||
|
parameter: UnknownRecord,
|
||||||
|
serializeValue: (value: unknown) => string = stringifyExampleValue,
|
||||||
|
): string {
|
||||||
|
if (Array.isArray(value)) return value.map(serializeValue).join(",");
|
||||||
|
if (isRecord(value)) {
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
return parameter.explode === true
|
||||||
|
? entries
|
||||||
|
.map(([key, entryValue]) => `${serializeValue(key)}=${serializeValue(entryValue)}`)
|
||||||
|
.join(",")
|
||||||
|
: entries.flat().map(serializeValue).join(",");
|
||||||
|
}
|
||||||
|
return serializeValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parameterExample(parameter: UnknownRecord, importState: ImportState): string {
|
function parameterExample(parameter: UnknownRecord, importState: ImportState): string {
|
||||||
|
return stringifyExampleValue(parameterExampleValue(parameter, importState));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parameterExampleValue(parameter: UnknownRecord, importState: ImportState): unknown {
|
||||||
const directExample = firstPresent(parameter.example, firstExampleValue(parameter.examples));
|
const directExample = firstPresent(parameter.example, firstExampleValue(parameter.examples));
|
||||||
if (directExample != null) return stringifyExampleValue(directExample);
|
if (directExample != null) return directExample;
|
||||||
return stringifyExampleValue(schemaToExample(importState.resolve(parameter.schema), importState));
|
if (isRecord(parameter.content)) {
|
||||||
|
const mediaType = toRecord(Object.values(parameter.content)[0]);
|
||||||
|
return mediaTypeExample(mediaType, importState);
|
||||||
|
}
|
||||||
|
return schemaToExample(importState.resolve(parameter.schema), importState);
|
||||||
}
|
}
|
||||||
|
|
||||||
function importBody({
|
function importBody({
|
||||||
@@ -1374,10 +1603,13 @@ function buildOAuthVariablesByScheme(
|
|||||||
|
|
||||||
function mergeHeaders(...headerGroups: HttpRequestHeader[][]): HttpRequestHeader[] {
|
function mergeHeaders(...headerGroups: HttpRequestHeader[][]): HttpRequestHeader[] {
|
||||||
const headers: HttpRequestHeader[] = [];
|
const headers: HttpRequestHeader[] = [];
|
||||||
for (const header of headerGroups.flat()) {
|
for (const group of headerGroups) {
|
||||||
const existing = headers.find((h) => h.name.toLowerCase() === header.name.toLowerCase());
|
const namesFromEarlierGroups = new Set(headers.map((header) => header.name.toLowerCase()));
|
||||||
if (existing == null) {
|
for (const header of group) {
|
||||||
headers.push(header);
|
const name = header.name.toLowerCase();
|
||||||
|
if (name === "cookie" || !namesFromEarlierGroups.has(name)) {
|
||||||
|
headers.push(header);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return headers;
|
return headers;
|
||||||
|
|||||||
@@ -163,18 +163,13 @@ Responses:
|
|||||||
"model": "http_request",
|
"model": "http_request",
|
||||||
"name": "Retrieve one version of a particular API",
|
"name": "Retrieve one version of a particular API",
|
||||||
"sortPriority": 5,
|
"sortPriority": 5,
|
||||||
"url": "\${[baseUrl]}/specs/:provider/:api.json",
|
"url": "\${[baseUrl]}/specs/:provider/2.1.0.json",
|
||||||
"urlParameters": [
|
"urlParameters": [
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":provider",
|
"name": ":provider",
|
||||||
"value": "apis.guru",
|
"value": "apis.guru",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"name": ":api",
|
|
||||||
"value": "2.1.0",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
},
|
},
|
||||||
@@ -207,7 +202,7 @@ Responses:
|
|||||||
"model": "http_request",
|
"model": "http_request",
|
||||||
"name": "Retrieve one version of a particular API with a serviceName.",
|
"name": "Retrieve one version of a particular API with a serviceName.",
|
||||||
"sortPriority": 6,
|
"sortPriority": 6,
|
||||||
"url": "\${[baseUrl]}/specs/:provider/:service/:api.json",
|
"url": "\${[baseUrl]}/specs/:provider/:service/2.1.0.json",
|
||||||
"urlParameters": [
|
"urlParameters": [
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -219,11 +214,6 @@ Responses:
|
|||||||
"name": ":service",
|
"name": ":service",
|
||||||
"value": "graph",
|
"value": "graph",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"name": ":api",
|
|
||||||
"value": "2.1.0",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
},
|
},
|
||||||
@@ -256,14 +246,8 @@ Responses:
|
|||||||
"model": "http_request",
|
"model": "http_request",
|
||||||
"name": "List all APIs for a particular provider",
|
"name": "List all APIs for a particular provider",
|
||||||
"sortPriority": 7,
|
"sortPriority": 7,
|
||||||
"url": "\${[baseUrl]}/:provider.json",
|
"url": "\${[baseUrl]}/apis.guru.json",
|
||||||
"urlParameters": [
|
"urlParameters": [],
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"name": ":provider",
|
|
||||||
"value": "apis.guru",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -860,13 +844,7 @@ Responses:
|
|||||||
- 200: Sucessful authentication.
|
- 200: Sucessful authentication.
|
||||||
- 401: Unsuccessful authentication.",
|
- 401: Unsuccessful authentication.",
|
||||||
"folderId": "GENERATE_ID::FOLDER_1",
|
"folderId": "GENERATE_ID::FOLDER_1",
|
||||||
"headers": [
|
"headers": [],
|
||||||
{
|
|
||||||
"enabled": false,
|
|
||||||
"name": "Authorization",
|
|
||||||
"value": "",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"id": "GENERATE_ID::HTTP_REQUEST_15",
|
"id": "GENERATE_ID::HTTP_REQUEST_15",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"model": "http_request",
|
"model": "http_request",
|
||||||
|
|||||||
@@ -766,6 +766,194 @@ describe("importer-openapi", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Imports cookie and content-based parameters", async () => {
|
||||||
|
const imported = await convertOpenApi(
|
||||||
|
JSON.stringify({
|
||||||
|
openapi: "3.0.4",
|
||||||
|
info: { title: "Parameter Test", version: "1.0.0" },
|
||||||
|
paths: {
|
||||||
|
"/items": {
|
||||||
|
get: {
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "session",
|
||||||
|
in: "cookie",
|
||||||
|
required: true,
|
||||||
|
schema: { type: "string", example: "abc" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "debug",
|
||||||
|
in: "cookie",
|
||||||
|
schema: { type: "string", example: "verbose" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "X-Filter",
|
||||||
|
in: "header",
|
||||||
|
required: true,
|
||||||
|
content: { "text/plain": { example: "active" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(imported?.resources.httpRequests[0]?.headers).toEqual([
|
||||||
|
{ enabled: true, name: "X-Filter", value: "active" },
|
||||||
|
{ enabled: true, name: "Cookie", value: "session=abc" },
|
||||||
|
{ enabled: false, name: "Cookie", value: "debug=verbose" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Preserves parameter cookies alongside cookie API-key authentication", async () => {
|
||||||
|
const imported = await convertOpenApi(
|
||||||
|
JSON.stringify({
|
||||||
|
openapi: "3.0.4",
|
||||||
|
info: { title: "Authenticated Cookie Test", version: "1.0.0" },
|
||||||
|
paths: {
|
||||||
|
"/items": {
|
||||||
|
get: {
|
||||||
|
security: [{ basicAuth: [], cookieKey: [] }],
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "session",
|
||||||
|
in: "cookie",
|
||||||
|
required: true,
|
||||||
|
schema: { type: "string", example: "abc" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "debug",
|
||||||
|
in: "cookie",
|
||||||
|
schema: { type: "string", example: "verbose" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
basicAuth: { type: "http", scheme: "basic" },
|
||||||
|
cookieKey: { type: "apiKey", in: "cookie", name: "api_key" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(imported?.resources.httpRequests[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
authenticationType: "basic",
|
||||||
|
headers: [
|
||||||
|
{ enabled: true, name: "Cookie", value: "api_key=${[auth_cookie_key_key]}" },
|
||||||
|
{ enabled: true, name: "Cookie", value: "session=abc" },
|
||||||
|
{ enabled: false, name: "Cookie", value: "debug=verbose" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Serializes structured query parameters according to style and explode", async () => {
|
||||||
|
const imported = await convertOpenApi(
|
||||||
|
JSON.stringify({
|
||||||
|
openapi: "3.0.4",
|
||||||
|
info: { title: "Serialization Test", version: "1.0.0" },
|
||||||
|
paths: {
|
||||||
|
"/items": {
|
||||||
|
get: {
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "filter",
|
||||||
|
in: "query",
|
||||||
|
required: true,
|
||||||
|
style: "deepObject",
|
||||||
|
explode: true,
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
role: { example: "admin" },
|
||||||
|
active: { example: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tags",
|
||||||
|
in: "query",
|
||||||
|
style: "form",
|
||||||
|
explode: true,
|
||||||
|
schema: { type: "array", example: ["one", "two"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([
|
||||||
|
{ enabled: true, name: "filter[role]", value: "admin" },
|
||||||
|
{ enabled: true, name: "filter[active]", value: "true" },
|
||||||
|
{ enabled: false, name: "tags", value: "one" },
|
||||||
|
{ enabled: false, name: "tags", value: "two" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Emits executable label and matrix path serializations", async () => {
|
||||||
|
const imported = await convertOpenApi(
|
||||||
|
JSON.stringify({
|
||||||
|
openapi: "3.0.4",
|
||||||
|
info: { title: "Path Serialization Test", version: "1.0.0" },
|
||||||
|
paths: {
|
||||||
|
"/labels/{labels}/matrix/{coordinates}/scalar/{color}/report.{format}": {
|
||||||
|
get: {
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "labels",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
style: "label",
|
||||||
|
explode: true,
|
||||||
|
schema: { type: "array", example: ["one/two", "three"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "coordinates",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
style: "matrix",
|
||||||
|
explode: true,
|
||||||
|
schema: { type: "object", example: { x: "1;spoof=2", y: 2 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "format",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: { type: "string", example: "json/evil" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "color",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
style: "label",
|
||||||
|
schema: { type: "string", example: "blue" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(imported?.resources.httpRequests[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: "${[baseUrl]}/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2/scalar/.blue/report.json%2Fevil",
|
||||||
|
urlParameters: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("Prefers operation-level consumes for Swagger bodies", async () => {
|
test("Prefers operation-level consumes for Swagger bodies", async () => {
|
||||||
const imported = await convertOpenApi(
|
const imported = await convertOpenApi(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
Reference in New Issue
Block a user