#![cfg_attr(docsrs, feature(doc_cfg))]
use std::{fs::File, io::Read, path::Path};
pub use errors::{Error, Result};
use serde::{Deserialize, Deserializer, Serialize, de};
use serde_json::{Map, Value};
pub mod v1_0_0;
pub mod v2_0_0;
pub mod v2_1_0;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct SchemaVersion {
major: u64,
minor: u64,
patch: u64,
}
pub mod errors {
use thiserror::Error;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Error)]
pub enum Error {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[cfg(feature = "yaml")]
#[cfg_attr(docsrs, doc(cfg(feature = "yaml")))]
#[error("YAML error: {0}")]
Yaml(#[from] yaml_serde::Error),
#[cfg(feature = "yaml")]
#[cfg_attr(docsrs, doc(cfg(feature = "yaml")))]
#[error("failed to parse collection as JSON ({json}) or YAML ({yaml})")]
Parse {
json: serde_json::Error,
yaml: yaml_serde::Error,
},
#[error("expected the Postman Collection document root to be an object")]
InvalidDocumentShape,
#[error(
"missing Postman Collection file version; expected a supported v2 info.schema value or the v1 collection shape"
)]
MissingSpecFileVersion,
#[error("could not determine Postman Collection file version from schema ({schema})")]
UnrecognizedSpecFileVersion { schema: String },
#[error("unsupported Postman Collection file version: {version}")]
UnsupportedSpecFileVersion { version: String },
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PostmanCollectionVersion {
#[allow(non_camel_case_types)]
V1_0_0,
#[allow(non_camel_case_types)]
V2_0_0,
#[allow(non_camel_case_types)]
V2_1_0,
}
#[derive(Clone, Debug, Serialize, PartialEq)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)]
pub enum PostmanCollection {
#[allow(non_camel_case_types)]
V1_0_0(v1_0_0::Spec),
#[allow(non_camel_case_types)]
V2_0_0(v2_0_0::Spec),
#[allow(non_camel_case_types)]
V2_1_0(v2_1_0::Spec),
}
impl<'de> Deserialize<'de> for PostmanCollection {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
Self::from_value(value).map_err(de::Error::custom)
}
}
impl PostmanCollection {
fn from_value(value: Value) -> Result<Self> {
match detect_version(&value)? {
PostmanCollectionVersion::V1_0_0 => {
Ok(Self::V1_0_0(serde_json::from_value::<v1_0_0::Spec>(value)?))
}
PostmanCollectionVersion::V2_0_0 => {
Ok(Self::V2_0_0(serde_json::from_value::<v2_0_0::Spec>(value)?))
}
PostmanCollectionVersion::V2_1_0 => {
Ok(Self::V2_1_0(serde_json::from_value::<v2_1_0::Spec>(value)?))
}
}
}
pub fn version(&self) -> PostmanCollectionVersion {
match self {
Self::V1_0_0(_) => PostmanCollectionVersion::V1_0_0,
Self::V2_0_0(_) => PostmanCollectionVersion::V2_0_0,
Self::V2_1_0(_) => PostmanCollectionVersion::V2_1_0,
}
}
pub fn name(&self) -> &str {
match self {
Self::V1_0_0(spec) => &spec.name,
Self::V2_0_0(spec) => &spec.info.name,
Self::V2_1_0(spec) => &spec.info.name,
}
}
}
pub fn from_path<P>(path: P) -> Result<PostmanCollection>
where
P: AsRef<Path>,
{
from_reader(File::open(path)?)
}
pub fn from_str(input: &str) -> Result<PostmanCollection> {
from_slice(input.as_bytes())
}
pub fn from_slice(input: &[u8]) -> Result<PostmanCollection> {
#[cfg(feature = "yaml")]
let value = match serde_json::from_slice::<Value>(input) {
Ok(value) => value,
Err(json) => match yaml_serde::from_slice::<Value>(input) {
Ok(value) => value,
Err(yaml) => return Err(Error::Parse { json, yaml }),
},
};
#[cfg(not(feature = "yaml"))]
let value = serde_json::from_slice::<Value>(input)?;
PostmanCollection::from_value(value)
}
pub fn from_reader<R>(mut read: R) -> Result<PostmanCollection>
where
R: Read,
{
let mut bytes = Vec::new();
read.read_to_end(&mut bytes)?;
from_slice(&bytes)
}
#[cfg(feature = "yaml")]
#[cfg_attr(docsrs, doc(cfg(feature = "yaml")))]
pub fn to_yaml(spec: &PostmanCollection) -> Result<String> {
Ok(yaml_serde::to_string(spec)?)
}
pub fn to_json(spec: &PostmanCollection) -> Result<String> {
Ok(serde_json::to_string_pretty(spec)?)
}
fn detect_version(value: &Value) -> Result<PostmanCollectionVersion> {
let object = value.as_object().ok_or(Error::InvalidDocumentShape)?;
if let Some(version) = version_from_schema(object)? {
return Ok(version);
}
if is_v1_document(object) {
return Ok(PostmanCollectionVersion::V1_0_0);
}
if looks_like_v2_document(object) {
return Err(Error::MissingSpecFileVersion);
}
Err(Error::InvalidDocumentShape)
}
fn is_v1_document(object: &Map<String, Value>) -> bool {
object.contains_key("id")
&& object.contains_key("name")
&& object.contains_key("order")
&& object.contains_key("requests")
}
fn looks_like_v2_document(object: &Map<String, Value>) -> bool {
object.contains_key("info") || object.contains_key("item")
}
fn version_from_schema(object: &Map<String, Value>) -> Result<Option<PostmanCollectionVersion>> {
let Some(schema) = object
.get("info")
.and_then(Value::as_object)
.and_then(|info| info.get("schema"))
.and_then(Value::as_str)
else {
return Ok(None);
};
let version =
extract_schema_version(schema).ok_or_else(|| Error::UnrecognizedSpecFileVersion {
schema: schema.to_owned(),
})?;
match version {
SchemaVersion {
major: 2,
minor: 0,
patch: 0,
} => Ok(Some(PostmanCollectionVersion::V2_0_0)),
SchemaVersion {
major: 2,
minor: 1,
patch: 0,
} => Ok(Some(PostmanCollectionVersion::V2_1_0)),
version => Err(Error::UnsupportedSpecFileVersion {
version: format!("{}.{}.{}", version.major, version.minor, version.patch),
}),
}
}
fn extract_schema_version(schema: &str) -> Option<SchemaVersion> {
schema
.split(|character: char| !(character.is_ascii_alphanumeric() || character == '.'))
.filter_map(|segment| segment.strip_prefix('v'))
.find_map(parse_schema_version)
}
fn parse_schema_version(candidate: &str) -> Option<SchemaVersion> {
let mut parts = candidate.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some(SchemaVersion {
major,
minor,
patch,
})
}
#[cfg(test)]
mod tests {
use std::fs::File;
use std::io::Write;
use glob::glob;
use super::*;
fn collection_fixture_glob() -> String {
format!(
"{}/tests/fixtures/collection/**/*.json",
env!("CARGO_MANIFEST_DIR")
)
}
fn schema_fixture_root() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("schema")
}
fn schema_fixture_version(name: &str) -> PostmanCollectionVersion {
let schema_fixture = read_file(schema_fixture_root().join(name));
let schema_json: serde_json::Value =
serde_json::from_str(&schema_fixture).expect("schema fixture should parse as JSON");
let schema_id = schema_json
.get("$id")
.and_then(serde_json::Value::as_str)
.expect("schema fixture should contain a $id");
match extract_schema_version(schema_id).expect("schema fixture id should contain a version")
{
SchemaVersion {
major: 1,
minor: 0,
patch: 0,
} => PostmanCollectionVersion::V1_0_0,
SchemaVersion {
major: 2,
minor: 0,
patch: 0,
} => PostmanCollectionVersion::V2_0_0,
SchemaVersion {
major: 2,
minor: 1,
patch: 0,
} => PostmanCollectionVersion::V2_1_0,
version => panic!(
"unexpected schema fixture version {}.{}.{}",
version.major, version.minor, version.patch
),
}
}
fn read_file<P>(path: P) -> String
where
P: AsRef<Path>,
{
let mut f = File::open(path).unwrap();
let mut content = String::new();
f.read_to_string(&mut content).unwrap();
content
}
fn write_to_file<P>(path: P, filename: &str, data: &str)
where
P: AsRef<Path> + std::fmt::Debug,
{
println!(" Saving string to {:?}...", path);
std::fs::create_dir_all(&path).unwrap();
let full_filename = path.as_ref().to_path_buf().join(filename);
let mut f = File::create(&full_filename).unwrap();
f.write_all(data.as_bytes()).unwrap();
}
fn normalize_json_value(value: &mut serde_json::Value) {
match value {
serde_json::Value::Array(values) => {
for value in values {
normalize_json_value(value);
}
}
serde_json::Value::Object(map) => {
map.retain(|_, value| {
normalize_json_value(value);
!value.is_null()
});
}
_ => {}
}
}
fn convert_json_str_to_json(json_str: &str) -> String {
let mut json: serde_json::Value = serde_json::from_str(json_str).unwrap();
normalize_json_value(&mut json);
serde_json::to_string_pretty(&json).unwrap()
}
fn compare_spec_through_json(
input_file: &Path,
save_path_base: &Path,
) -> (String, String, String) {
let source_json_str = read_file(input_file);
let expected_json_str = convert_json_str_to_json(&source_json_str);
let parsed_spec = from_path(input_file).unwrap();
let mut parsed_spec_json: serde_json::Value = serde_json::to_value(parsed_spec).unwrap();
normalize_json_value(&mut parsed_spec_json);
let parsed_spec_json_str = serde_json::to_string_pretty(&parsed_spec_json).unwrap();
let api_filename = input_file.file_name().unwrap().to_str().unwrap().to_owned();
let mut save_path = save_path_base.to_path_buf();
save_path.push("json_to_json");
write_to_file(&save_path, &api_filename, &expected_json_str);
let mut save_path = save_path_base.to_path_buf();
save_path.push("json_to_spec_to_json");
write_to_file(&save_path, &api_filename, &parsed_spec_json_str);
(api_filename, parsed_spec_json_str, expected_json_str)
}
#[test]
fn can_deserialize() {
for entry in glob(&collection_fixture_glob()).expect("Failed to read glob pattern") {
let entry = entry.unwrap();
let path = entry.as_path();
println!("Testing if {:?} is deserializable", path);
from_path(path).unwrap();
}
}
#[test]
fn can_deserialize_and_reserialize() {
let save_path_base: std::path::PathBuf =
["target", "tests", "can_deserialize_and_reserialize"]
.iter()
.collect();
let mut invalid_diffs = Vec::new();
for entry in glob(&collection_fixture_glob()).expect("Failed to read glob pattern") {
let entry = entry.unwrap();
let path = entry.as_path();
println!("Testing if {:?} is deserializable", path);
let (api_filename, parsed_spec_json_str, spec_json_str) =
compare_spec_through_json(path, &save_path_base);
if parsed_spec_json_str != spec_json_str {
invalid_diffs.push((api_filename, parsed_spec_json_str, spec_json_str));
}
}
for invalid_diff in &invalid_diffs {
println!("File {} failed JSON comparison!", invalid_diff.0);
}
assert_eq!(invalid_diffs.len(), 0);
}
#[test]
fn detects_versions_for_sample_collections() {
let fixture_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("collection");
assert!(matches!(
from_path(fixture_root.join("swagger-petstore-v1.0.0.json")).unwrap(),
PostmanCollection::V1_0_0(_)
));
assert!(matches!(
from_path(fixture_root.join("swagger-petstore-v2.0.0.json")).unwrap(),
PostmanCollection::V2_0_0(_)
));
assert!(matches!(
from_path(fixture_root.join("swagger-petstore-v2.1.0.json")).unwrap(),
PostmanCollection::V2_1_0(_)
));
}
#[test]
fn extracts_versions_from_schema_fixture_identifiers() {
assert_eq!(
schema_fixture_version("postman-collection-v1.0.0.json"),
PostmanCollectionVersion::V1_0_0
);
assert_eq!(
schema_fixture_version("postman-collection-v2.0.0.json"),
PostmanCollectionVersion::V2_0_0
);
assert_eq!(
schema_fixture_version("postman-collection-v2.1.0.json"),
PostmanCollectionVersion::V2_1_0
);
}
}