use super::display::ApiCompatIssueDisplay;
use crate::output::Styles;
use drift::ChangeClass;
use dropshot_api_manager_types::ApiIdent;
use std::collections::{BTreeMap, BTreeSet, HashMap};
#[derive(Debug)]
pub(crate) struct ApiCompatIssue {
pub(super) blessed_base: DocumentBasePath,
pub(super) generated_base: DocumentBasePath,
pub(super) changes: BTreeSet<SubpathChange>,
pub(super) tree: PathTree,
pub(super) blessed_value: Option<serde_json::Value>,
pub(super) generated_value: Option<serde_json::Value>,
}
impl ApiCompatIssue {
pub(crate) fn blessed_json(&self) -> String {
to_json_pretty(self.blessed_value.as_ref())
}
pub(crate) fn generated_json(&self) -> String {
to_json_pretty(self.generated_value.as_ref())
}
pub(crate) fn display<'a>(
&'a self,
styles: &'a Styles,
status: CompatRenderStatus,
) -> ApiCompatIssueDisplay<'a> {
ApiCompatIssueDisplay { issue: self, styles, status, wrap_width: None }
}
pub(super) fn is_same_change_as(&self, other: &Self) -> bool {
let Self {
blessed_base,
generated_base,
changes,
tree: _,
blessed_value,
generated_value,
} = self;
*blessed_base == other.blessed_base
&& *generated_base == other.generated_base
&& *changes == other.changes
&& *blessed_value == other.blessed_value
&& *generated_value == other.generated_value
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CompatIssueLocation<'a> {
pub(crate) api: &'a ApiIdent,
pub(crate) version: &'a semver::Version,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum CompatRenderStatus {
FirstOccurrence { anchor: Option<usize> },
Duplicate { anchor: usize },
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(super) struct SubpathChange {
pub(super) class: ChangeClass,
pub(super) message: String,
pub(super) old_subpath: DocumentPath,
pub(super) new_subpath: DocumentPath,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(super) enum DocumentBasePath {
Component(DocumentPath),
Endpoint {
name: DocumentPath,
operation_id: Option<String>,
},
PathsRoot,
Other(DocumentPath),
}
impl DocumentBasePath {
pub(super) fn classify(
path: DocumentPath,
op_ids: &OperationIdMap<'_>,
) -> Self {
match path.segments.as_slice() {
[a, _, _] if a == "components" => Self::Component(path),
[a, _, _] if a == "paths" => {
let operation_id = op_ids.get(&path).map(|s| s.to_string());
Self::Endpoint { name: path, operation_id }
}
[a] if a == "paths" => Self::PathsRoot,
_ => Self::Other(path),
}
}
pub(super) fn is_paths_root(&self) -> bool {
matches!(self, Self::PathsRoot)
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(super) struct PathTreeKey {
pub(super) base: DocumentBasePath,
pub(super) subpath: DocumentPath,
}
impl PathTreeKey {
pub(super) fn parse(entry: &str, op_ids: &OperationIdMap<'_>) -> Self {
const BASE_SEGMENTS: usize = 3;
let without_ref = entry.strip_suffix("/$ref").unwrap_or(entry);
let (base, subpath) =
DocumentPath::parse(without_ref).split_at(BASE_SEGMENTS);
Self { base: DocumentBasePath::classify(base, op_ids), subpath }
}
}
#[derive(Debug, Default)]
pub(super) struct PathTree {
pub(super) children: BTreeMap<PathTreeKey, PathTree>,
}
impl PathTree {
pub(super) fn insert(
&mut self,
chain: impl IntoIterator<Item = PathTreeKey>,
) {
let mut curr = self;
for key in chain {
curr = curr.children.entry(key).or_default();
}
}
}
pub(super) type OperationIdMap<'a> = HashMap<DocumentPath, &'a str>;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(super) struct DocumentPath {
pub(super) segments: Vec<String>,
}
impl DocumentPath {
pub(super) fn parse(pointer: &str) -> Self {
let trimmed = pointer.trim_matches('#').trim_matches('/');
if trimmed.is_empty() {
return Self::root();
}
let segments =
trimmed.split('/').map(unescape_pointer_component).collect();
Self { segments }
}
pub(super) fn root() -> Self {
Self { segments: Vec::new() }
}
pub(super) fn is_root(&self) -> bool {
self.segments.is_empty()
}
pub(super) fn split_at(mut self, n: usize) -> (Self, Self) {
if n >= self.segments.len() {
return (self, Self::root());
}
let tail = Self { segments: self.segments.split_off(n) };
(self, tail)
}
}
pub(super) fn unescape_pointer_component(component: &str) -> String {
component.replace("~1", "/").replace("~0", "~")
}
fn to_json_pretty(value: Option<&serde_json::Value>) -> String {
match value {
Some(value) => serde_json::to_string_pretty(value)
.expect("serializing serde_json::Value should always succeed"),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn path(segments: &[&str]) -> DocumentPath {
DocumentPath {
segments: segments.iter().map(|&s| s.to_owned()).collect(),
}
}
#[test]
fn test_path_tree_key_parse() {
let ops = OperationIdMap::new();
let cases: &[(&str, DocumentBasePath, &[&str])] = &[
(
"#/components/schemas/Foo/properties/x/$ref",
DocumentBasePath::Component(path(&[
"components",
"schemas",
"Foo",
])),
&["properties", "x"],
),
(
"#/paths/~1users/get/responses/200/content/application~1json/schema/$ref",
DocumentBasePath::Endpoint {
name: path(&["paths", "/users", "get"]),
operation_id: None,
},
&["responses", "200", "content", "application/json", "schema"],
),
(
"#/components/schemas/Foo",
DocumentBasePath::Component(path(&[
"components",
"schemas",
"Foo",
])),
&[],
),
];
for (entry, want_base, want_subpath) in cases {
let key = PathTreeKey::parse(entry, &ops);
assert_eq!(&key.base, want_base, "base for entry {entry}");
assert_eq!(
key.subpath.segments.as_slice(),
*want_subpath,
"subpath for entry {entry}",
);
}
}
}