mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-21 10:54:08 +02:00
Port OpenAPI improvements from the integration branch (#602)
This commit is contained in:
+136
-11
@@ -78,8 +78,19 @@ impl SendableHttpRequest {
|
||||
}
|
||||
|
||||
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) =
|
||||
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;
|
||||
} else {
|
||||
@@ -205,16 +216,23 @@ fn append_graphql_query_params(url: &str, body: &BTreeMap<String, serde_json::Va
|
||||
}
|
||||
|
||||
fn build_headers(r: &HttpRequest) -> Vec<(String, String)> {
|
||||
r.headers
|
||||
.iter()
|
||||
.filter_map(|h| {
|
||||
if h.enabled && !h.name.is_empty() {
|
||||
Some((h.name.clone(), h.value.clone()))
|
||||
} else {
|
||||
None
|
||||
// RFC 6265 allows only one Cookie field, so enabled Cookie rows fold into
|
||||
// the first one
|
||||
let mut headers: Vec<(String, String)> = Vec::new();
|
||||
for h in &r.headers {
|
||||
if !h.enabled || h.name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if h.name.eq_ignore_ascii_case("cookie") {
|
||||
if let Some(existing) = headers.iter_mut().find(|e| e.0.eq_ignore_ascii_case("cookie"))
|
||||
{
|
||||
existing.1 = format!("{}; {}", existing.1, h.value);
|
||||
continue;
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
headers.push((h.name.clone(), h.value.clone()));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
async fn build_body(
|
||||
@@ -494,7 +512,114 @@ mod tests {
|
||||
use bytes::Bytes;
|
||||
use serde_json::json;
|
||||
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())]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sendable_request_merges_enabled_cookie_rows_into_one_field() {
|
||||
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,
|
||||
},
|
||||
HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "cookie".to_string(),
|
||||
value: "theme=dark".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; theme=dark".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]
|
||||
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::connection_or_tx::ConnectionOrTx;
|
||||
use crate::error::Result;
|
||||
@@ -144,9 +144,7 @@ impl<'a> ClientDb<'a> {
|
||||
headers.append(&mut workspace_headers);
|
||||
}
|
||||
|
||||
headers.append(&mut folder.headers.clone());
|
||||
|
||||
Ok(headers)
|
||||
Ok(merge_headers(headers, folder.headers.clone()))
|
||||
}
|
||||
|
||||
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::error::Result;
|
||||
use crate::models::{
|
||||
@@ -110,9 +110,7 @@ impl<'a> ClientDb<'a> {
|
||||
metadata.append(&mut workspace_metadata);
|
||||
}
|
||||
|
||||
metadata.append(&mut grpc_request.metadata.clone());
|
||||
|
||||
Ok(dedupe_headers(metadata))
|
||||
Ok(merge_headers(metadata, grpc_request.metadata.clone()))
|
||||
}
|
||||
|
||||
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::error::Result;
|
||||
use crate::models::{
|
||||
@@ -96,9 +96,7 @@ impl<'a> ClientDb<'a> {
|
||||
headers.append(&mut workspace_headers);
|
||||
}
|
||||
|
||||
headers.append(&mut http_request.headers.clone());
|
||||
|
||||
Ok(dedupe_headers(headers))
|
||||
Ok(merge_headers(headers, http_request.headers.clone()))
|
||||
}
|
||||
|
||||
pub fn resolve_settings_for_http_request(
|
||||
@@ -172,3 +170,44 @@ impl<'a> ClientDb<'a> {
|
||||
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;
|
||||
|
||||
use crate::models::HttpRequestHeader;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Deduplicate headers by name (case-insensitive), keeping the latest (most specific) value.
|
||||
/// Preserves the order of first occurrence for each header name.
|
||||
pub(crate) fn dedupe_headers(headers: Vec<HttpRequestHeader>) -> Vec<HttpRequestHeader> {
|
||||
let mut index_by_name: HashMap<String, usize> = HashMap::new();
|
||||
let mut deduped: Vec<HttpRequestHeader> = Vec::new();
|
||||
for header in headers {
|
||||
let key = header.name.to_lowercase();
|
||||
if let Some(&idx) = index_by_name.get(&key) {
|
||||
deduped[idx] = header;
|
||||
} else {
|
||||
index_by_name.insert(key, deduped.len());
|
||||
deduped.push(header);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
/// Merge a more-specific header layer over its parent. Names in the child replace
|
||||
/// inherited values case-insensitively, while duplicates declared together in
|
||||
/// either layer remain independent entries.
|
||||
pub(crate) fn merge_headers(
|
||||
mut parent: Vec<HttpRequestHeader>,
|
||||
child: Vec<HttpRequestHeader>,
|
||||
) -> Vec<HttpRequestHeader> {
|
||||
let child_names = child.iter().map(|header| header.name.to_lowercase()).collect::<HashSet<_>>();
|
||||
parent.retain(|header| !child_names.contains(&header.name.to_lowercase()));
|
||||
parent.extend(child);
|
||||
parent
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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::error::Result;
|
||||
use crate::models::{
|
||||
@@ -103,13 +103,9 @@ impl<'a> ClientDb<'a> {
|
||||
&self,
|
||||
websocket_request: &WebsocketRequest,
|
||||
) -> Result<Vec<HttpRequestHeader>> {
|
||||
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
|
||||
|
||||
// Resolved headers should be from furthest to closest ancestor, to override logically.
|
||||
let mut headers = Vec::new();
|
||||
|
||||
headers.append(&mut workspace.headers.clone());
|
||||
|
||||
if let Some(folder_id) = websocket_request.folder_id.clone() {
|
||||
let parent_folder = self.get_folder(&folder_id)?;
|
||||
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 websocket_request.headers.clone());
|
||||
|
||||
Ok(dedupe_headers(headers))
|
||||
Ok(merge_headers(headers, websocket_request.headers.clone()))
|
||||
}
|
||||
|
||||
pub fn resolve_settings_for_websocket_request(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::merge_headers;
|
||||
use crate::blob_manager::BlobManager;
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Result;
|
||||
@@ -144,9 +145,7 @@ impl<'a> ClientDb<'a> {
|
||||
}
|
||||
|
||||
pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> {
|
||||
let mut headers = default_headers();
|
||||
headers.extend(workspace.headers.clone());
|
||||
headers
|
||||
merge_headers(default_headers(), workspace.headers.clone())
|
||||
}
|
||||
|
||||
pub fn resolve_settings_for_workspace(
|
||||
|
||||
@@ -37,6 +37,7 @@ const BODY_CONTENT_TYPE_PREFERENCE = [
|
||||
"text/plain",
|
||||
];
|
||||
const MAX_EXAMPLE_DEPTH = 8;
|
||||
const MAX_SCHEMA_RESOLUTION_DEPTH = MAX_EXAMPLE_DEPTH;
|
||||
const MAX_EXAMPLE_PROPERTIES = 25;
|
||||
const MAX_DESCRIPTION_ITEMS = 40;
|
||||
const MAX_NAME_LENGTH = 100;
|
||||
@@ -330,20 +331,14 @@ function importOperation({
|
||||
headers: inheritedAuthentication.headers,
|
||||
urlParameters: inheritedAuthentication.urlParameters,
|
||||
};
|
||||
const pathExampleValues = new Map(
|
||||
parameters
|
||||
.map((p) => importState.resolve(p))
|
||||
.filter(isRecord)
|
||||
.filter((p) => stringAt(p, "in") === "path" && stringAt(p, "name") != null)
|
||||
.map((p) => [stringAt(p, "name") as string, parameterExample(p, importState)] as const),
|
||||
);
|
||||
const { url, placeholderNames } = buildOperationUrl(
|
||||
const url = buildOperationUrl(
|
||||
operationBaseUrl({ operation, pathItem, requestBaseUrl, serverOverrides }),
|
||||
path,
|
||||
pathExampleValues,
|
||||
parameters,
|
||||
importState,
|
||||
);
|
||||
const urlParameters = [
|
||||
...importUrlParameters({ importState, parameters, placeholderNames }),
|
||||
...importUrlParameters({ importState, parameters, path }),
|
||||
...authentication.urlParameters,
|
||||
];
|
||||
const headers = mergeHeaders(
|
||||
@@ -700,25 +695,64 @@ function findOrCreateFolderId({
|
||||
|
||||
/**
|
||||
* Yaak's `:name` placeholders only substitute when they span a whole path
|
||||
* segment. A template elsewhere in a segment, like `/report.{format}`, would
|
||||
* import as text that never substitutes, and its leftover parameter would then
|
||||
* be sent as a query parameter — so those get their example inlined instead.
|
||||
* segment and hold a single plain value. Templates elsewhere in a segment
|
||||
* (like `/report.{format}`), styled ones (label, matrix), and array or object
|
||||
* values get their serialized example inlined instead — a placeholder row
|
||||
* cannot express them, and its leftover parameter would leak into the query
|
||||
* string.
|
||||
*/
|
||||
function buildOperationUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
inlineValues: Map<string, string>,
|
||||
): { url: string; placeholderNames: Set<string> } {
|
||||
const placeholderNames = new Set<string>();
|
||||
const converted = path.replaceAll(/(^|\/){([^}/]+)}(?=[/?#:]|$)/g, (_, prefix, name) => {
|
||||
placeholderNames.add(name);
|
||||
return `${prefix}:${name}`;
|
||||
});
|
||||
const inlined = converted.replaceAll(/{([^}/]+)}/g, (match, name) => {
|
||||
const value = inlineValues.get(name);
|
||||
return value == null || value === "" ? match : value;
|
||||
});
|
||||
return { url: joinUrlParts(baseUrl, inlined), placeholderNames };
|
||||
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);
|
||||
const serialized = isRecord(parameter.content)
|
||||
? encodePathComponent(serializeContentParameter(parameter, importState))
|
||||
: serializePathParameter(name, value, parameter, encodePathComponent);
|
||||
// A missing example stays a visible template rather than vanishing
|
||||
if (serialized.length === 0) continue;
|
||||
serializedPath = serializedPath.replaceAll(`{${name}}`, serialized);
|
||||
}
|
||||
return joinUrlParts(baseUrl, serializedPath.replaceAll(/(^|\/){([^}/]+)}(?=[/?#:]|$)/g, "$1:$2"));
|
||||
}
|
||||
|
||||
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));
|
||||
// A `:name` placeholder matches from the segment start up to a literal `:`,
|
||||
// so `{id}` and `{id}:cancel` stay placeholders while `report.{format}` can't
|
||||
const placeholderExpressible = matchingSegments.every(
|
||||
(segment) =>
|
||||
segment === template ||
|
||||
(segment.startsWith(template) && segment[template.length] === ":"),
|
||||
);
|
||||
if (matchingSegments.length === 0 || !placeholderExpressible) 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 {
|
||||
@@ -809,47 +843,84 @@ function trimTrailingSlashes(value: string): string {
|
||||
function importUrlParameters({
|
||||
importState,
|
||||
parameters,
|
||||
placeholderNames,
|
||||
path,
|
||||
}: {
|
||||
importState: ImportState;
|
||||
parameters: unknown[];
|
||||
placeholderNames: Set<string>;
|
||||
path: string;
|
||||
}): HttpUrlParameter[] {
|
||||
return parameters
|
||||
.map((p) => importState.resolve(p))
|
||||
.filter(isRecord)
|
||||
.filter(
|
||||
(p) =>
|
||||
stringAt(p, "in") === "query" ||
|
||||
(stringAt(p, "in") === "path" && placeholderNames.has(stringAt(p, "name") ?? "")),
|
||||
)
|
||||
.flatMap((p) => {
|
||||
const name = stringAt(p, "name") ?? "";
|
||||
if (name.length === 0) return [];
|
||||
.filter((p) => stringAt(p, "in") === "query" || stringAt(p, "in") === "path")
|
||||
.flatMap((p) => serializeUrlParameter(p, importState, path))
|
||||
.filter(({ name }) => name.length > 0);
|
||||
}
|
||||
|
||||
// Path parameters are required by definition, and a disabled one would
|
||||
// leave the literal `:name` in the sent URL even for sloppy specs that
|
||||
// omit `required: true`
|
||||
const enabled = p.required === true || stringAt(p, "in") === "path";
|
||||
if (stringAt(p, "in") === "query") {
|
||||
const raw = rawParameterExample(p, importState);
|
||||
if (Array.isArray(raw)) {
|
||||
const { separator } = queryArraySerialization(p);
|
||||
if (separator == null) {
|
||||
return raw.map((item) => ({ enabled, name, value: stringifyExampleValue(item) }));
|
||||
}
|
||||
return [{ enabled, name, value: raw.map(stringifyExampleValue).join(separator) }];
|
||||
}
|
||||
}
|
||||
function serializeUrlParameter(
|
||||
parameter: UnknownRecord,
|
||||
importState: ImportState,
|
||||
path: string,
|
||||
): HttpUrlParameter[] {
|
||||
const name = stringAt(parameter, "name") ?? "";
|
||||
const location = stringAt(parameter, "in");
|
||||
// Path parameters are required by definition, and a disabled one would
|
||||
// leave the literal `:name` in the sent URL even for sloppy specs that
|
||||
// omit `required: true`
|
||||
const enabled = parameter.required === true || location === "path";
|
||||
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 [];
|
||||
const serialized = serializePathParameter(name, value, parameter);
|
||||
// An empty path segment makes a URL that matches nothing, so the name at
|
||||
// least keeps the request sendable and shows what belongs there
|
||||
return [{ enabled, name: `:${name}`, value: serialized.length > 0 ? serialized : name }];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
enabled,
|
||||
name: stringAt(p, "in") === "path" ? `:${name}` : name,
|
||||
value: parameterExample(p, importState),
|
||||
},
|
||||
];
|
||||
});
|
||||
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 { separator } = queryArraySerialization(parameter);
|
||||
if (separator == null) {
|
||||
return value.map((entryValue) => ({
|
||||
enabled,
|
||||
name,
|
||||
value: stringifyExampleValue(entryValue),
|
||||
}));
|
||||
}
|
||||
return [{ enabled, name, value: value.map(stringifyExampleValue).join(separator) }];
|
||||
}
|
||||
|
||||
return [{ enabled, name, value: stringifyExampleValue(value) }];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -891,12 +962,16 @@ function importHeaderParameters({
|
||||
.map((p) => ({
|
||||
enabled: p.required === true,
|
||||
name: stringAt(p, "name") ?? "",
|
||||
value: parameterExample(p, importState),
|
||||
value: serializeParameterValue(p, importState),
|
||||
}))
|
||||
.filter(({ name }) => name.length > 0);
|
||||
}
|
||||
|
||||
/** Yaak has no cookie parameter row, so cookie parameters become the header they would produce */
|
||||
/**
|
||||
* Yaak has no cookie parameter row, so each cookie parameter becomes its own
|
||||
* Cookie header. Rows stay individually toggleable and the send path merges
|
||||
* the enabled ones into a single header.
|
||||
*/
|
||||
function importCookieHeader({
|
||||
importState,
|
||||
parameters,
|
||||
@@ -904,47 +979,131 @@ function importCookieHeader({
|
||||
importState: ImportState;
|
||||
parameters: unknown[];
|
||||
}): HttpRequestHeader[] {
|
||||
const cookieParameters = parameters
|
||||
return parameters
|
||||
.map((p) => importState.resolve(p))
|
||||
.filter(isRecord)
|
||||
.filter((p) => stringAt(p, "in") === "cookie")
|
||||
.filter((p) => (stringAt(p, "name") ?? "").length > 0);
|
||||
if (cookieParameters.length === 0) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
enabled: cookieParameters.some((p) => p.required === true),
|
||||
.map((p) => ({
|
||||
enabled: p.required === true,
|
||||
name: "Cookie",
|
||||
value: cookieParameters
|
||||
.map((p) => `${stringAt(p, "name")}=${parameterExample(p, importState)}`)
|
||||
.join("; "),
|
||||
},
|
||||
];
|
||||
value: serializeCookieParameter(p, importState),
|
||||
}))
|
||||
.filter(({ value }) => value.length > 0);
|
||||
}
|
||||
|
||||
function rawParameterExample(parameter: UnknownRecord, importState: ImportState): unknown {
|
||||
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;
|
||||
// Exploded pairs are cookie pairs, which RFC 6265 separates with "; "
|
||||
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);
|
||||
}
|
||||
|
||||
/** A parameter described by a media type serializes as that media type */
|
||||
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 parameterExampleValue(parameter: UnknownRecord, importState: ImportState): unknown {
|
||||
const directExample = firstPresent(
|
||||
parameter.example,
|
||||
firstExampleValue(parameter.examples, importState),
|
||||
);
|
||||
if (directExample != null) return directExample;
|
||||
if (isRecord(parameter.content)) {
|
||||
const mediaType = toRecord(Object.values(parameter.content)[0]);
|
||||
return mediaTypeExample(mediaType, importState);
|
||||
}
|
||||
// Swagger 2 parameters carry the schema keywords (type, items, default)
|
||||
// directly on the parameter object
|
||||
return schemaToExample(importState.resolve(parameter.schema ?? parameter), importState);
|
||||
return schemaToExample(parameter.schema ?? parameter, importState);
|
||||
}
|
||||
|
||||
function parameterExample(parameter: UnknownRecord, importState: ImportState): string {
|
||||
const raw = rawParameterExample(parameter, importState);
|
||||
// Simple/csv style, the default everywhere but query strings
|
||||
const example = Array.isArray(raw)
|
||||
? raw.map(stringifyExampleValue).join(",")
|
||||
: stringifyExampleValue(raw);
|
||||
// An empty path segment makes a URL that matches nothing, so the name at
|
||||
// least keeps the request sendable and shows what belongs there
|
||||
if (example === "" && stringAt(parameter, "in") === "path") {
|
||||
return stringAt(parameter, "name") ?? "";
|
||||
}
|
||||
return example;
|
||||
return serializeSimpleParameter(parameterExampleValue(parameter, importState), parameter);
|
||||
}
|
||||
|
||||
function importBody({
|
||||
@@ -975,14 +1134,21 @@ function importBody({
|
||||
toArray(operation.consumes ?? spec.consumes).find(
|
||||
(c): c is string => typeof c === "string",
|
||||
) ?? "application/json";
|
||||
const schema = importState.resolveSchema(bodyParameter.schema);
|
||||
const isBinary = stringAt(schema, "format") === "binary";
|
||||
return {
|
||||
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||
bodyType: yaakBodyType(contentType),
|
||||
body: {
|
||||
text: formatBodyText(
|
||||
schemaToExample(importState.resolve(bodyParameter.schema), importState),
|
||||
),
|
||||
},
|
||||
bodyType: isBinary ? "binary" : yaakBodyType(contentType),
|
||||
body: isBinary
|
||||
? {}
|
||||
: {
|
||||
text: formatMediaTypeBody(
|
||||
contentType,
|
||||
schemaToExample(schema, importState),
|
||||
schema,
|
||||
importState,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1000,11 +1166,15 @@ function importBody({
|
||||
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||
bodyType: contentType,
|
||||
body: {
|
||||
form: formParameters.map((p) => ({
|
||||
enabled: p.required === true,
|
||||
name: stringAt(p, "name") ?? "",
|
||||
value: parameterExample(p, importState),
|
||||
})),
|
||||
form: formParameters.map((p) => {
|
||||
const base = {
|
||||
enabled: p.required === true,
|
||||
name: stringAt(p, "name") ?? "",
|
||||
};
|
||||
return stringAt(p, "type") === "file"
|
||||
? { ...base, file: "" }
|
||||
: { ...base, value: parameterExample(p, importState) };
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1020,33 +1190,196 @@ function importBodyFromContent(importState: ImportState, content: UnknownRecord)
|
||||
const bodyType = yaakBodyType(contentType);
|
||||
|
||||
if (bodyType === "application/x-www-form-urlencoded" || bodyType === "multipart/form-data") {
|
||||
const example = mediaTypeExample(mediaType, importState);
|
||||
return {
|
||||
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||
bodyType,
|
||||
body: {
|
||||
form: schemaToFormParameters(importState.resolve(mediaType.schema), importState),
|
||||
form: schemaToFormParameters(
|
||||
mediaType.schema,
|
||||
importState,
|
||||
isRecord(example) ? example : undefined,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const schema = importState.resolveSchema(mediaType.schema);
|
||||
const isBinary = bodyType === "binary" || stringAt(schema, "format") === "binary";
|
||||
|
||||
return {
|
||||
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||
bodyType,
|
||||
body:
|
||||
bodyType === "binary"
|
||||
? {}
|
||||
: { text: formatBodyText(mediaTypeExample(mediaType, importState)) },
|
||||
bodyType: isBinary ? "binary" : bodyType,
|
||||
body: isBinary
|
||||
? {}
|
||||
: {
|
||||
text: formatMediaTypeBody(
|
||||
contentType,
|
||||
mediaTypeExample(mediaType, importState),
|
||||
schema,
|
||||
importState,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function chooseContentType(contentTypes: string[]): string | null {
|
||||
const jsonType = contentTypes.find((contentType) => mediaTypeOf(contentType).endsWith("+json"));
|
||||
for (const preference of BODY_CONTENT_TYPE_PREFERENCE) {
|
||||
const exact = contentTypes.find((c) => mediaTypeOf(c) === preference);
|
||||
if (exact != null) return exact;
|
||||
// A +json suffix type ranks with JSON, ahead of the other preferences
|
||||
if (preference === "application/json" && jsonType != null) return jsonType;
|
||||
}
|
||||
return contentTypes[0] ?? null;
|
||||
}
|
||||
|
||||
function formatMediaTypeBody(
|
||||
contentType: string,
|
||||
example: unknown,
|
||||
schema: unknown,
|
||||
importState: ImportState,
|
||||
): string {
|
||||
const mediaType = mediaTypeOf(contentType);
|
||||
if (mediaType === "application/xml" || mediaType === "text/xml" || mediaType.endsWith("+xml")) {
|
||||
return typeof example === "string"
|
||||
? example
|
||||
: valueToXml(example, schema, importState, "root", true);
|
||||
}
|
||||
if (mediaType === "application/json" || mediaType.endsWith("+json")) {
|
||||
// A string example may be pre-serialized JSON; otherwise it needs quoting
|
||||
// to be a valid JSON document
|
||||
if (typeof example === "string") {
|
||||
try {
|
||||
JSON.parse(example);
|
||||
return example;
|
||||
} catch {
|
||||
return JSON.stringify(example);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(example, null, 2) ?? "";
|
||||
}
|
||||
return formatBodyText(example);
|
||||
}
|
||||
|
||||
function valueToXml(
|
||||
value: unknown,
|
||||
schema: unknown,
|
||||
importState: ImportState,
|
||||
elementName: string,
|
||||
isDocumentRoot = false,
|
||||
): string {
|
||||
const resolvedSchema = toRecord(importState.resolveSchema(schema));
|
||||
const schemaXml = toRecord(resolvedSchema.xml);
|
||||
if (Array.isArray(value)) {
|
||||
const itemSchema = importState.resolveSchema(resolvedSchema.items);
|
||||
const shouldWrap = schemaXml.wrapped === true || isDocumentRoot;
|
||||
const itemName =
|
||||
stringAt(toRecord(itemSchema).xml, "name") ??
|
||||
(shouldWrap ? (stringAt(schemaXml, "name") ?? elementName) : elementName);
|
||||
const items = value.map((item) => valueToXml(item, itemSchema, importState, itemName)).join("");
|
||||
return shouldWrap ? xmlElement(elementName, schemaXml, items) : items;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
const properties = toRecord(resolvedSchema.properties);
|
||||
const entries = Object.entries(value).map(([name, propertyValue]) => {
|
||||
const propertySchema = toRecord(importState.resolveSchema(properties[name]));
|
||||
return { name, propertyValue, propertySchema, xml: toRecord(propertySchema.xml) };
|
||||
});
|
||||
const usedPrefixes = new Set(["xml", "xmlns"]);
|
||||
const prefixesByNamespace = new Map<string, string>();
|
||||
for (const xml of [schemaXml, ...entries.map(({ xml }) => xml)]) {
|
||||
const namespace = stringAt(xml, "namespace");
|
||||
const prefix = stringAt(xml, "prefix");
|
||||
if (prefix == null || prefix.length === 0) continue;
|
||||
usedPrefixes.add(prefix);
|
||||
if (namespace != null && namespace.length > 0 && !prefixesByNamespace.has(namespace)) {
|
||||
prefixesByNamespace.set(namespace, prefix);
|
||||
}
|
||||
}
|
||||
const attributes: string[] = [];
|
||||
const attributeNamespaces: UnknownRecord[] = [];
|
||||
const children: string[] = [];
|
||||
for (const { name, propertyValue, propertySchema, xml } of entries) {
|
||||
if (xml.attribute === true) {
|
||||
const attributeXml = qualifyXmlAttribute(xml, usedPrefixes, prefixesByNamespace);
|
||||
attributes.push(
|
||||
`${qualifiedXmlName(name, attributeXml)}="${escapeXml(stringifyExampleValue(propertyValue))}"`,
|
||||
);
|
||||
attributeNamespaces.push(attributeXml);
|
||||
} else {
|
||||
children.push(valueToXml(propertyValue, propertySchema, importState, name));
|
||||
}
|
||||
}
|
||||
return xmlElement(elementName, schemaXml, children.join(""), attributes, attributeNamespaces);
|
||||
}
|
||||
return xmlElement(elementName, schemaXml, escapeXml(stringifyExampleValue(value)));
|
||||
}
|
||||
|
||||
function xmlElement(
|
||||
fallbackName: string,
|
||||
xml: UnknownRecord,
|
||||
content: string,
|
||||
attributes: string[] = [],
|
||||
additionalNamespaces: UnknownRecord[] = [],
|
||||
): string {
|
||||
const name = qualifiedXmlName(fallbackName, xml);
|
||||
const namespaces = new Map<string, string>();
|
||||
for (const metadata of [xml, ...additionalNamespaces]) {
|
||||
const namespace = stringAt(metadata, "namespace");
|
||||
if (namespace == null || namespace.length === 0) continue;
|
||||
const prefix = stringAt(metadata, "prefix");
|
||||
namespaces.set(prefix == null || prefix.length === 0 ? "xmlns" : `xmlns:${prefix}`, namespace);
|
||||
}
|
||||
const namespaceAttributes = [...namespaces].map(
|
||||
([attribute, namespace]) => `${attribute}="${escapeXml(namespace)}"`,
|
||||
);
|
||||
const attributeText = [...namespaceAttributes, ...attributes].join(" ");
|
||||
const openingTag = attributeText.length > 0 ? `<${name} ${attributeText}>` : `<${name}>`;
|
||||
return `${openingTag}${content}</${name}>`;
|
||||
}
|
||||
|
||||
function qualifyXmlAttribute(
|
||||
xml: UnknownRecord,
|
||||
usedPrefixes: Set<string>,
|
||||
prefixesByNamespace: Map<string, string>,
|
||||
): UnknownRecord {
|
||||
const namespace = stringAt(xml, "namespace");
|
||||
const declaredPrefix = stringAt(xml, "prefix");
|
||||
if (namespace != null && namespace.length === 0) {
|
||||
const { prefix: _prefix, ...unqualifiedXml } = xml;
|
||||
return unqualifiedXml;
|
||||
}
|
||||
if (namespace == null || (declaredPrefix != null && declaredPrefix.length > 0)) return xml;
|
||||
|
||||
// Namespaced attributes need a prefix to be well-formed, so reuse the
|
||||
// namespace's existing prefix or mint one
|
||||
const existingPrefix = prefixesByNamespace.get(namespace);
|
||||
if (existingPrefix != null) return { ...xml, prefix: existingPrefix };
|
||||
|
||||
let suffix = 1;
|
||||
while (usedPrefixes.has(`ns${suffix}`)) suffix++;
|
||||
const generatedPrefix = `ns${suffix}`;
|
||||
usedPrefixes.add(generatedPrefix);
|
||||
prefixesByNamespace.set(namespace, generatedPrefix);
|
||||
return { ...xml, prefix: generatedPrefix };
|
||||
}
|
||||
|
||||
function qualifiedXmlName(fallbackName: string, xml: UnknownRecord): string {
|
||||
const name = stringAt(xml, "name") ?? fallbackName;
|
||||
const prefix = stringAt(xml, "prefix");
|
||||
return prefix == null || prefix.length === 0 ? name : `${prefix}:${name}`;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function mediaTypeOf(contentType: string): string {
|
||||
return contentType.toLowerCase().split(";")[0]?.trim() ?? "";
|
||||
}
|
||||
@@ -1075,25 +1408,21 @@ function mediaTypeExample(mediaType: UnknownRecord, importState: ImportState): u
|
||||
firstExampleValue(mediaType.examples, importState),
|
||||
);
|
||||
if (directExample != null) return directExample;
|
||||
return schemaToExample(importState.resolve(mediaType.schema), importState);
|
||||
return schemaToExample(mediaType.schema, importState);
|
||||
}
|
||||
|
||||
function schemaToFormParameters(schema: unknown, importState: ImportState) {
|
||||
const resolvedSchema = toRecord(importState.resolve(schema));
|
||||
const sources = [
|
||||
...toArray(resolvedSchema.allOf).map((s) => toRecord(importState.resolve(s))),
|
||||
resolvedSchema,
|
||||
];
|
||||
const required = sources
|
||||
.flatMap((s) => toArray(s.required))
|
||||
.filter((name): name is string => typeof name === "string");
|
||||
const properties = [
|
||||
...new Map(sources.flatMap((s) => Object.entries(toRecord(s.properties)))).entries(),
|
||||
].slice(0, MAX_EXAMPLE_PROPERTIES);
|
||||
function schemaToFormParameters(schema: unknown, importState: ImportState, example?: UnknownRecord) {
|
||||
const resolvedSchema = toRecord(importState.resolveSchema(schema));
|
||||
const required = toArray(resolvedSchema.required).filter(
|
||||
(name): name is string => typeof name === "string",
|
||||
);
|
||||
const properties = Object.entries(toRecord(resolvedSchema.properties))
|
||||
.filter(([, property]) => toRecord(importState.resolveSchema(property)).readOnly !== true)
|
||||
.slice(0, MAX_EXAMPLE_PROPERTIES);
|
||||
|
||||
return properties.map(([name, property]) => {
|
||||
const resolvedProperty = toRecord(importState.resolve(property));
|
||||
const example = schemaToExample(resolvedProperty, importState);
|
||||
const resolvedProperty = toRecord(importState.resolveSchema(property));
|
||||
const propertyExample = example?.[name] ?? schemaToExample(resolvedProperty, importState);
|
||||
const base = {
|
||||
enabled: required.includes(name),
|
||||
name,
|
||||
@@ -1101,7 +1430,7 @@ function schemaToFormParameters(schema: unknown, importState: ImportState) {
|
||||
if (stringAt(resolvedProperty, "format") === "binary") {
|
||||
return { ...base, file: "" };
|
||||
}
|
||||
return { ...base, value: stringifyExampleValue(example) };
|
||||
return { ...base, value: stringifyExampleValue(propertyExample) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1113,12 +1442,20 @@ function schemaToExample(
|
||||
): unknown {
|
||||
if (depth > MAX_EXAMPLE_DEPTH) return {};
|
||||
|
||||
const resolved = importState.resolve(schema, visitedRefs);
|
||||
const schemaRecord = toRecord(schema);
|
||||
const ref = stringAt(schemaRecord, "$ref");
|
||||
const closesReferenceCycle = ref != null && visitedRefs.has(ref);
|
||||
const nextVisitedRefs = new Set(visitedRefs);
|
||||
if (ref != null) nextVisitedRefs.add(ref);
|
||||
|
||||
const resolved = importState.resolveSchema(schema, visitedRefs);
|
||||
if (!isRecord(resolved)) return "";
|
||||
if (closesReferenceCycle && Object.keys(resolved).length === 0) return {};
|
||||
|
||||
const explicitExample = firstPresent(
|
||||
resolved.example,
|
||||
firstExampleValue(resolved.examples, importState),
|
||||
resolved.const,
|
||||
resolved.default,
|
||||
);
|
||||
if (explicitExample != null) return coerceToDeclaredType(explicitExample, resolved);
|
||||
@@ -1126,34 +1463,78 @@ function schemaToExample(
|
||||
const enumValues = toArray(resolved.enum);
|
||||
if (enumValues.length > 0) return enumValues[0];
|
||||
|
||||
const propertyExample = schemaPropertiesToExample(resolved, importState, depth, nextVisitedRefs);
|
||||
const allOf = toArray(resolved.allOf);
|
||||
if (allOf.length > 0) {
|
||||
const merged = allOf.reduce<UnknownRecord>((merged, childSchema) => {
|
||||
const childExample = schemaToExample(childSchema, importState, depth + 1, visitedRefs);
|
||||
return isRecord(childExample) ? { ...merged, ...childExample } : merged;
|
||||
const compositionExample = allOf.reduce<UnknownRecord>((merged, childSchema) => {
|
||||
const childExample = schemaToExample(childSchema, importState, depth + 1, nextVisitedRefs);
|
||||
return isRecord(childExample) ? mergeExampleRecords(merged, childExample) : merged;
|
||||
}, {});
|
||||
// Sibling properties are their own constraint alongside the allOf branches
|
||||
return { ...merged, ...objectPropertiesExample(resolved, importState, depth, visitedRefs) };
|
||||
return mergeExampleRecords(compositionExample, propertyExample);
|
||||
}
|
||||
|
||||
const oneOf = toArray(resolved.oneOf);
|
||||
const anyOf = toArray(resolved.anyOf);
|
||||
if (oneOf.length > 0 || anyOf.length > 0) {
|
||||
return schemaToExample(oneOf[0] ?? anyOf[0], importState, depth + 1, visitedRefs);
|
||||
const compositionExample = schemaToExample(
|
||||
oneOf[0] ?? anyOf[0],
|
||||
importState,
|
||||
depth + 1,
|
||||
nextVisitedRefs,
|
||||
);
|
||||
return Object.keys(propertyExample).length > 0
|
||||
? mergeExampleRecords(isRecord(compositionExample) ? compositionExample : {}, propertyExample)
|
||||
: compositionExample;
|
||||
}
|
||||
|
||||
const type = inferSchemaType(resolved);
|
||||
if (type === "array") {
|
||||
return [schemaToExample(resolved.items, importState, depth + 1, visitedRefs)];
|
||||
}
|
||||
if (type === "object") {
|
||||
return objectPropertiesExample(resolved, importState, depth, visitedRefs);
|
||||
return [schemaToExample(resolved.items, importState, depth + 1, nextVisitedRefs)];
|
||||
}
|
||||
if (type === "object") return propertyExample;
|
||||
if (type === "integer" || type === "number") return 0;
|
||||
if (type === "boolean") return false;
|
||||
return FORMAT_EXAMPLES[stringAt(resolved, "format") ?? ""] ?? "";
|
||||
}
|
||||
|
||||
/** Request examples omit readOnly properties, which only appear in responses */
|
||||
function schemaPropertiesToExample(
|
||||
schema: UnknownRecord,
|
||||
importState: ImportState,
|
||||
depth: number,
|
||||
visitedRefs: Set<string>,
|
||||
): UnknownRecord {
|
||||
const required = toArray(schema.required).filter(
|
||||
(name): name is string => typeof name === "string",
|
||||
);
|
||||
const properties = Object.entries(toRecord(schema.properties))
|
||||
.filter(([, property]) => toRecord(importState.resolveSchema(property)).readOnly !== true)
|
||||
.sort(([a], [b]) => {
|
||||
const aRequired = required.includes(a);
|
||||
const bRequired = required.includes(b);
|
||||
return aRequired === bRequired ? 0 : aRequired ? -1 : 1;
|
||||
});
|
||||
|
||||
return Object.fromEntries(
|
||||
properties
|
||||
.slice(0, MAX_EXAMPLE_PROPERTIES)
|
||||
.map(([name, property]) => [
|
||||
name,
|
||||
schemaToExample(property, importState, depth + 1, visitedRefs),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function mergeExampleRecords(base: UnknownRecord, overlay: UnknownRecord): UnknownRecord {
|
||||
const merged = { ...base };
|
||||
for (const [name, value] of Object.entries(overlay)) {
|
||||
const baseValue = merged[name];
|
||||
merged[name] =
|
||||
isRecord(baseValue) && isRecord(value) ? mergeExampleRecords(baseValue, value) : value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
const FORMAT_EXAMPLES: Record<string, string> = {
|
||||
"date-time": "2026-01-01T00:00:00Z",
|
||||
date: "2026-01-01",
|
||||
@@ -1194,30 +1575,6 @@ function coerceToDeclaredType(example: unknown, schema: UnknownRecord): unknown
|
||||
return example;
|
||||
}
|
||||
|
||||
function objectPropertiesExample(
|
||||
schema: UnknownRecord,
|
||||
importState: ImportState,
|
||||
depth: number,
|
||||
visitedRefs: Set<string>,
|
||||
): UnknownRecord {
|
||||
const required = toArray(schema.required).filter(
|
||||
(name): name is string => typeof name === "string",
|
||||
);
|
||||
const properties = Object.entries(toRecord(schema.properties)).sort(([a], [b]) => {
|
||||
const aRequired = required.includes(a);
|
||||
const bRequired = required.includes(b);
|
||||
return aRequired === bRequired ? 0 : aRequired ? -1 : 1;
|
||||
});
|
||||
|
||||
return Object.fromEntries(
|
||||
properties
|
||||
.slice(0, MAX_EXAMPLE_PROPERTIES)
|
||||
.map(([name, property]) => [
|
||||
name,
|
||||
schemaToExample(property, importState, depth + 1, visitedRefs),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function inferSchemaType(schema: UnknownRecord): string {
|
||||
const rawType = schema.type;
|
||||
@@ -1618,12 +1975,16 @@ function buildOAuthVariablesByScheme(
|
||||
);
|
||||
}
|
||||
|
||||
/** Earlier groups win on a name collision; Cookie rows all pass through since the send path merges them */
|
||||
function mergeHeaders(...headerGroups: HttpRequestHeader[][]): HttpRequestHeader[] {
|
||||
const headers: HttpRequestHeader[] = [];
|
||||
for (const header of headerGroups.flat()) {
|
||||
const existing = headers.find((h) => h.name.toLowerCase() === header.name.toLowerCase());
|
||||
if (existing == null) {
|
||||
headers.push(header);
|
||||
for (const group of headerGroups) {
|
||||
const namesFromEarlierGroups = new Set(headers.map((header) => header.name.toLowerCase()));
|
||||
for (const header of group) {
|
||||
const name = header.name.toLowerCase();
|
||||
if (name === "cookie" || !namesFromEarlierGroups.has(name)) {
|
||||
headers.push(header);
|
||||
}
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
@@ -1733,7 +2094,120 @@ class ImportState {
|
||||
return value;
|
||||
}
|
||||
|
||||
const resolved = value.$ref
|
||||
return this.resolve(this.#resolveLocalReference(value.$ref), nextVisitedRefs);
|
||||
}
|
||||
|
||||
/** Schema Objects allow `$ref` siblings in OpenAPI 3.1 and later. */
|
||||
resolveSchema(value: unknown, visitedRefs = new Set<string>(), compositionDepth = 0): unknown {
|
||||
if (!isRecord(value)) return value;
|
||||
|
||||
let resolved: unknown = value;
|
||||
const structureVisitedRefs = new Set(visitedRefs);
|
||||
const siblingLayers: UnknownRecord[] = [];
|
||||
while (isRecord(resolved) && typeof resolved.$ref === "string") {
|
||||
const ref = resolved.$ref;
|
||||
const siblings = Object.fromEntries(
|
||||
Object.entries(resolved).filter(([key]) => key !== "$ref"),
|
||||
);
|
||||
if (structureVisitedRefs.has(ref)) {
|
||||
resolved = siblings;
|
||||
break;
|
||||
}
|
||||
if (!ref.startsWith("#/")) {
|
||||
this.#unresolvedRefs.add(ref);
|
||||
break;
|
||||
}
|
||||
|
||||
structureVisitedRefs.add(ref);
|
||||
siblingLayers.push(siblings);
|
||||
resolved = this.#resolveLocalReference(ref);
|
||||
}
|
||||
if (!isRecord(resolved)) return resolved;
|
||||
|
||||
let merged: UnknownRecord = resolved;
|
||||
for (let index = siblingLayers.length - 1; index >= 0; index--) {
|
||||
merged = this.#mergeSchemaObjects(merged, siblingLayers[index] ?? {});
|
||||
}
|
||||
return this.#mergeAllOfStructure(merged, structureVisitedRefs, compositionDepth);
|
||||
}
|
||||
|
||||
#mergeAllOfStructure(
|
||||
schema: UnknownRecord,
|
||||
visitedRefs: Set<string>,
|
||||
depth: number,
|
||||
): UnknownRecord {
|
||||
if (depth > MAX_SCHEMA_RESOLUTION_DEPTH) return schema;
|
||||
const allOf = toArray(schema.allOf);
|
||||
if (allOf.length === 0) return schema;
|
||||
|
||||
const composed = allOf.reduce<UnknownRecord>((merged, childSchema) => {
|
||||
const child = this.resolveSchema(childSchema, new Set(visitedRefs), depth + 1);
|
||||
if (!isRecord(child)) return merged;
|
||||
const childStructure = Object.fromEntries(
|
||||
Object.entries(child).filter(([key]) => key !== "allOf"),
|
||||
);
|
||||
return this.#mergeSchemaObjects(merged, childStructure);
|
||||
}, {});
|
||||
return this.#mergeSchemaObjects(composed, schema);
|
||||
}
|
||||
|
||||
#mergeSchemaObjects(base: UnknownRecord, overlay: UnknownRecord, depth = 0): UnknownRecord {
|
||||
const merged: UnknownRecord = { ...base, ...overlay };
|
||||
if (depth > MAX_SCHEMA_RESOLUTION_DEPTH) return merged;
|
||||
|
||||
const baseProperties = toRecord(base.properties);
|
||||
const overlayProperties = toRecord(overlay.properties);
|
||||
const propertyNames = new Set([
|
||||
...Object.keys(baseProperties),
|
||||
...Object.keys(overlayProperties),
|
||||
]);
|
||||
if (propertyNames.size > 0) {
|
||||
merged.properties = Object.fromEntries(
|
||||
[...propertyNames].map((name) => {
|
||||
const baseProperty = baseProperties[name];
|
||||
const overlayProperty = overlayProperties[name];
|
||||
if (isRecord(baseProperty) && isRecord(overlayProperty)) {
|
||||
return [name, this.#mergeSchemaObjects(baseProperty, overlayProperty, depth + 1)];
|
||||
}
|
||||
return [name, overlayProperty ?? baseProperty];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const baseRequired = toArray(base.required).filter(
|
||||
(name): name is string => typeof name === "string",
|
||||
);
|
||||
const overlayRequired = toArray(overlay.required).filter(
|
||||
(name): name is string => typeof name === "string",
|
||||
);
|
||||
if (baseRequired.length > 0 || overlayRequired.length > 0) {
|
||||
merged.required = [...new Set([...baseRequired, ...overlayRequired])];
|
||||
}
|
||||
|
||||
const baseAllOf = toArray(base.allOf);
|
||||
const overlayAllOf = toArray(overlay.allOf);
|
||||
if (baseAllOf.length > 0 && overlayAllOf.length > 0) {
|
||||
merged.allOf = [...baseAllOf, ...overlayAllOf];
|
||||
}
|
||||
if (isRecord(base.xml) && isRecord(overlay.xml)) {
|
||||
merged.xml = { ...base.xml, ...overlay.xml };
|
||||
}
|
||||
if (isRecord(base.items) && isRecord(overlay.items)) {
|
||||
merged.items = this.#mergeSchemaObjects(base.items, overlay.items, depth + 1);
|
||||
}
|
||||
if (isRecord(base.additionalProperties) && isRecord(overlay.additionalProperties)) {
|
||||
merged.additionalProperties = this.#mergeSchemaObjects(
|
||||
base.additionalProperties,
|
||||
overlay.additionalProperties,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
#resolveLocalReference(ref: string): unknown {
|
||||
return ref
|
||||
.slice(2)
|
||||
.split("/")
|
||||
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||
@@ -1742,7 +2216,5 @@ class ImportState {
|
||||
Array.isArray(current) ? current[Number(part)] : toRecord(current)[part],
|
||||
this.#spec,
|
||||
);
|
||||
|
||||
return this.resolve(resolved, nextVisitedRefs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1219,7 +1219,8 @@ describe("importer-openapi", () => {
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.description).toContain("{{placeholders}}");
|
||||
expect(imported?.resources.httpRequests[0]?.body).toEqual({ text: "Hi {{name}}" });
|
||||
// Quoted to be a valid JSON document, braces intact
|
||||
expect(imported?.resources.httpRequests[0]?.body).toEqual({ text: '"Hi {{name}}"' });
|
||||
});
|
||||
|
||||
test("Ignores header parameters the spec reserves for other mechanisms", async () => {
|
||||
@@ -1269,8 +1270,10 @@ describe("importer-openapi", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// One row per cookie so each stays toggleable; the send path merges them
|
||||
expect(imported?.resources.httpRequests[0]?.headers).toEqual([
|
||||
{ enabled: true, name: "Cookie", value: "session=abc; theme=dark" },
|
||||
{ enabled: true, name: "Cookie", value: "session=abc" },
|
||||
{ enabled: false, name: "Cookie", value: "theme=dark" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1456,6 +1459,864 @@ describe("importer-openapi", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("Imports OpenAPI 3.1 schema reference siblings and examples", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Reference Examples", version: "1.0.0" },
|
||||
paths: {
|
||||
"/sibling": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
$ref: "#/components/schemas/Message",
|
||||
example: { text: "overridden by sibling" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
"/example-ref": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
examples: { sample: { $ref: "#/components/examples/Message" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
"/schema-values": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fromExamples: { type: "string", examples: ["first", "second"] },
|
||||
fromConst: { const: "fixed" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
"/sibling-form": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
schema: {
|
||||
$ref: "#/components/schemas/MessageForm",
|
||||
required: ["extra"],
|
||||
properties: { extra: { type: "string", default: "sibling" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
Message: { type: "object", properties: { text: { default: "base" } } },
|
||||
MessageForm: {
|
||||
$ref: "#/components/schemas/BaseMessageForm",
|
||||
required: ["middle"],
|
||||
properties: {
|
||||
middle: { type: "string", default: "intermediate" },
|
||||
optional: { type: "string", default: "optional" },
|
||||
},
|
||||
},
|
||||
BaseMessageForm: {
|
||||
type: "object",
|
||||
required: ["base"],
|
||||
properties: { base: { type: "string", default: "referenced" } },
|
||||
},
|
||||
},
|
||||
examples: {
|
||||
Message: { value: { text: "resolved example" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests.map((request) => request.body)).toEqual([
|
||||
{ text: JSON.stringify({ text: "overridden by sibling" }, null, 2) },
|
||||
{ text: JSON.stringify({ text: "resolved example" }, null, 2) },
|
||||
{ text: JSON.stringify({ fromExamples: "first", fromConst: "fixed" }, null, 2) },
|
||||
{
|
||||
form: [
|
||||
{ enabled: true, name: "base", value: "referenced" },
|
||||
{ enabled: true, name: "middle", value: "intermediate" },
|
||||
{ enabled: false, name: "optional", value: "optional" },
|
||||
{ enabled: true, name: "extra", value: "sibling" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Merges colliding and composed schema properties", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Composed Schema Examples", version: "1.0.0" },
|
||||
paths: {
|
||||
"/colliding-property": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/xml": {
|
||||
schema: {
|
||||
$ref: "#/components/schemas/BasePayload",
|
||||
properties: {
|
||||
shared: {
|
||||
xml: { name: "renamed" },
|
||||
properties: {
|
||||
local: { type: "string", default: "sibling" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
"/composition-siblings": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
allOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
shared: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fromBranch: { type: "string", default: "branch" },
|
||||
},
|
||||
},
|
||||
branchOnly: { type: "string", default: "branch" },
|
||||
},
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
shared: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fromSibling: { type: "string", default: "sibling" },
|
||||
},
|
||||
},
|
||||
siblingOnly: { type: "string", default: "sibling" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
"/composition-form": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
schema: {
|
||||
$ref: "#/components/schemas/ComposedForm",
|
||||
required: ["siblingField"],
|
||||
properties: {
|
||||
siblingField: { type: "string", default: "sibling" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
Shared: {
|
||||
type: "object",
|
||||
xml: { namespace: "urn:shared", prefix: "s" },
|
||||
properties: {
|
||||
inherited: { type: "string", default: "base" },
|
||||
},
|
||||
},
|
||||
BasePayload: {
|
||||
type: "object",
|
||||
xml: { name: "payload" },
|
||||
properties: {
|
||||
shared: {
|
||||
$ref: "#/components/schemas/Shared",
|
||||
xml: { name: "base-shared" },
|
||||
},
|
||||
},
|
||||
},
|
||||
ComposedForm: {
|
||||
allOf: [
|
||||
{
|
||||
type: "object",
|
||||
required: ["baseField"],
|
||||
properties: {
|
||||
baseField: { type: "string", default: "base" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests.map((request) => request.body)).toEqual([
|
||||
{
|
||||
text:
|
||||
'<payload><s:renamed xmlns:s="urn:shared">' +
|
||||
"<inherited>base</inherited><local>sibling</local>" +
|
||||
"</s:renamed></payload>",
|
||||
},
|
||||
{
|
||||
text: JSON.stringify(
|
||||
{
|
||||
shared: { fromBranch: "branch", fromSibling: "sibling" },
|
||||
branchOnly: "branch",
|
||||
siblingOnly: "sibling",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
{
|
||||
form: [
|
||||
{ enabled: true, name: "baseField", value: "base" },
|
||||
{ enabled: true, name: "siblingField", value: "sibling" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Stops circular schema references when generating examples", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Circular References", version: "1.0.0" },
|
||||
paths: {
|
||||
"/nodes": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/Node" } },
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
Node: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", example: "root" },
|
||||
child: {
|
||||
$ref: "#/components/schemas/Node",
|
||||
required: ["relationship"],
|
||||
properties: {
|
||||
relationship: { type: "string", example: "nested" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.body).toEqual({
|
||||
text: JSON.stringify({ name: "root", child: { relationship: "nested" } }, null, 2),
|
||||
});
|
||||
});
|
||||
|
||||
test("Bounds deeply colliding schema merges", async () => {
|
||||
const depth = 12_000;
|
||||
const nestedSchema = (leaf: string, levels: number) =>
|
||||
'{"type":"object","properties":{"next":'.repeat(levels) + leaf + "}}".repeat(levels);
|
||||
const baseSchema = nestedSchema('{"type":"string","default":"base"}', depth);
|
||||
const siblingProperty = nestedSchema('{"type":"string","example":"sibling"}', depth - 1);
|
||||
|
||||
const imported = await convertOpenApi(
|
||||
'{"openapi":"3.1.0","info":{"title":"Deep Merge","version":"1.0.0"},' +
|
||||
'"paths":{"/deep":{"post":{"requestBody":{"content":{"application/json":' +
|
||||
'{"schema":{"$ref":"#/components/schemas/DeepBase","properties":{"next":' +
|
||||
siblingProperty +
|
||||
'}}}}},"responses":{}}}},"components":{"schemas":{"DeepBase":' +
|
||||
baseSchema +
|
||||
"}}}",
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.body).toEqual({
|
||||
text: JSON.stringify(
|
||||
{
|
||||
next: {
|
||||
next: {
|
||||
next: {
|
||||
next: {
|
||||
next: {
|
||||
next: {
|
||||
next: {
|
||||
next: {
|
||||
next: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
test("Bounds deeply nested inline allOf schemas", async () => {
|
||||
const depth = 12_000;
|
||||
const schema =
|
||||
'{"allOf":['.repeat(depth) + '{"type":"string","example":"leaf"}' + "]}".repeat(depth);
|
||||
const imported = await convertOpenApi(
|
||||
'{"openapi":"3.1.0","info":{"title":"Deep allOf","version":"1.0.0"},' +
|
||||
'"paths":{"/deep":{"post":{"requestBody":{"content":{"application/json":{"schema":' +
|
||||
schema +
|
||||
'}}},"responses":{}}}}}',
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.body).toEqual({
|
||||
text: JSON.stringify({}, null, 2),
|
||||
});
|
||||
});
|
||||
|
||||
test("Resolves long local reference chains without truncating schemas", async () => {
|
||||
const depth = 12_000;
|
||||
const schemas: Record<string, unknown> = {
|
||||
[`Ref${depth}`]: {
|
||||
type: "object",
|
||||
properties: { target: { type: "string", example: "reached" } },
|
||||
},
|
||||
};
|
||||
for (let index = depth - 1; index >= 0; index--) {
|
||||
schemas[`Ref${index}`] = {
|
||||
$ref: `#/components/schemas/Ref${index + 1}`,
|
||||
...(index === 1 ? { properties: { middle: { type: "string", example: "sibling" } } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Long Reference Chain", version: "1.0.0" },
|
||||
paths: {
|
||||
"/long-ref": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
$ref: "#/components/schemas/Ref0",
|
||||
properties: { outer: { type: "string", example: "request" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: { schemas },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.body).toEqual({
|
||||
text: JSON.stringify({ target: "reached", middle: "sibling", outer: "request" }, null, 2),
|
||||
});
|
||||
});
|
||||
|
||||
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: "prefs",
|
||||
in: "cookie",
|
||||
required: true,
|
||||
schema: { type: "object", example: { theme: "dark", lang: "en" } },
|
||||
},
|
||||
{
|
||||
name: "colors",
|
||||
in: "cookie",
|
||||
required: true,
|
||||
explode: false,
|
||||
schema: { type: "array", example: ["red", "blue"] },
|
||||
},
|
||||
{
|
||||
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" },
|
||||
// Cookie pairs separate with "; ", never "&"
|
||||
{ enabled: true, name: "Cookie", value: "theme=dark; lang=en" },
|
||||
{ enabled: true, name: "Cookie", value: "colors=red,blue" },
|
||||
]);
|
||||
});
|
||||
|
||||
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("Serializes request examples according to their media type", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.4",
|
||||
info: { title: "Media Type Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/xml": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/xml": {
|
||||
schema: {
|
||||
type: "object",
|
||||
xml: { name: "user" },
|
||||
properties: { name: { type: "string", example: "Ada" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
"/json-string": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: { "application/json": { schema: { type: "string", example: "hello" } } },
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
"/json-preserialized": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "string", example: '{"already": "json"}' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.body).toEqual({
|
||||
text: "<user><name>Ada</name></user>",
|
||||
});
|
||||
expect(imported?.resources.httpRequests[0]?.bodyType).toBe("text/xml");
|
||||
expect(imported?.resources.httpRequests[1]?.body).toEqual({ text: '"hello"' });
|
||||
expect(imported?.resources.httpRequests[2]?.body).toEqual({ text: '{"already": "json"}' });
|
||||
});
|
||||
|
||||
test("Honors XML array wrapping and namespaces", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.4",
|
||||
info: { title: "XML Metadata Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/catalog": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/xml": {
|
||||
schema: {
|
||||
type: "object",
|
||||
xml: { name: "catalog", namespace: "urn:catalog", prefix: "c" },
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
example: "42",
|
||||
xml: { attribute: true, namespace: "urn:metadata", prefix: "m" },
|
||||
},
|
||||
externalId: {
|
||||
type: "string",
|
||||
example: "external",
|
||||
xml: {
|
||||
attribute: true,
|
||||
name: "external-id",
|
||||
namespace: "urn:external",
|
||||
prefix: "ns1",
|
||||
},
|
||||
},
|
||||
tenant: {
|
||||
type: "string",
|
||||
example: "acme",
|
||||
xml: { attribute: true, namespace: "urn:tenant" },
|
||||
},
|
||||
region: {
|
||||
type: "string",
|
||||
example: "west",
|
||||
xml: { attribute: true, namespace: "urn:tenant" },
|
||||
},
|
||||
legacy: {
|
||||
type: "string",
|
||||
example: "plain",
|
||||
xml: { attribute: true, namespace: "", prefix: "unbound" },
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
example: ["one", "two"],
|
||||
xml: {
|
||||
name: "tags",
|
||||
namespace: "urn:tags",
|
||||
prefix: "t",
|
||||
wrapped: true,
|
||||
},
|
||||
items: { type: "string", xml: { name: "tag" } },
|
||||
},
|
||||
aliases: {
|
||||
type: "array",
|
||||
example: ["Ada", "A"],
|
||||
xml: { name: "ignored", wrapped: false },
|
||||
items: {
|
||||
type: "string",
|
||||
xml: { name: "alias", namespace: "urn:aliases", prefix: "a" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
"/values": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/xml": {
|
||||
schema: {
|
||||
type: "array",
|
||||
example: ["one", "two"],
|
||||
xml: { name: "values", wrapped: false },
|
||||
items: { type: "string", xml: { name: "value" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.body).toEqual({
|
||||
text:
|
||||
'<c:catalog xmlns:c="urn:catalog" xmlns:m="urn:metadata" ' +
|
||||
'xmlns:ns1="urn:external" xmlns:ns2="urn:tenant" ' +
|
||||
'm:id="42" ns1:external-id="external" ns2:tenant="acme" ns2:region="west" ' +
|
||||
'legacy="plain">' +
|
||||
'<t:tags xmlns:t="urn:tags"><tag>one</tag><tag>two</tag></t:tags>' +
|
||||
'<a:alias xmlns:a="urn:aliases">Ada</a:alias>' +
|
||||
'<a:alias xmlns:a="urn:aliases">A</a:alias>' +
|
||||
"</c:catalog>",
|
||||
});
|
||||
expect(imported?.resources.httpRequests[1]?.body).toEqual({
|
||||
text: "<values><value>one</value><value>two</value></values>",
|
||||
});
|
||||
});
|
||||
|
||||
test("Omits read-only properties from generated request bodies", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.4",
|
||||
info: { title: "Read Only Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/users": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", readOnly: true, example: "server-id" },
|
||||
name: { type: "string", example: "Ada" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.body).toEqual({
|
||||
text: JSON.stringify({ name: "Ada" }, null, 2),
|
||||
});
|
||||
});
|
||||
|
||||
test("Imports Swagger 2 file parameters as file form entries", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
swagger: "2.0",
|
||||
info: { title: "File Upload Test", version: "1.0.0" },
|
||||
host: "example.com",
|
||||
consumes: ["multipart/form-data"],
|
||||
paths: {
|
||||
"/upload": {
|
||||
post: {
|
||||
parameters: [{ name: "upload", in: "formData", required: true, type: "file" }],
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.body).toEqual({
|
||||
form: [{ enabled: true, name: "upload", file: "" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("Serializes Swagger 2 XML request bodies as XML", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
swagger: "2.0",
|
||||
info: { title: "Swagger XML Test", version: "1.0.0" },
|
||||
host: "example.com",
|
||||
consumes: ["application/xml"],
|
||||
paths: {
|
||||
"/users": {
|
||||
post: {
|
||||
parameters: [
|
||||
{
|
||||
name: "user",
|
||||
in: "body",
|
||||
required: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
xml: { name: "user" },
|
||||
properties: { name: { type: "string", example: "Ada" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.body).toEqual({
|
||||
text: "<user><name>Ada</name></user>",
|
||||
});
|
||||
expect(imported?.resources.httpRequests[0]?.bodyType).toBe("text/xml");
|
||||
});
|
||||
|
||||
test("Reports references that point outside the document", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
|
||||
Reference in New Issue
Block a user