use crate::{
apis::ManagedApi, environment::ResolvedEnv,
spec_files_generated::GeneratedApiSpecFile,
};
use anyhow::Context;
use atomicwrites::AtomicFile;
use camino::{Utf8Path, Utf8PathBuf};
use dropshot_api_manager_types::{
ApiIdent, ApiSpecFileName, ManagedApiMetadata, ValidationBackend,
ValidationContext, Versions,
};
use openapiv3::OpenAPI;
use std::io::Write;
pub(crate) type DynValidationFn =
dyn Fn(&OpenAPI, ValidationContext<'_>) + Send + Sync;
pub fn validate(
env: &ResolvedEnv,
api: &ManagedApi,
is_latest: bool,
is_blessed: Option<bool>,
validation: Option<&DynValidationFn>,
generated: &GeneratedApiSpecFile,
) -> anyhow::Result<Vec<(Utf8PathBuf, CheckStatus)>> {
let openapi = generated.openapi();
let validation_result = validate_generated_openapi_document(
api,
openapi,
generated.spec_file_name(),
is_latest,
is_blessed,
validation,
)?;
let extra_files = validation_result
.extra_files
.into_iter()
.map(|(path, contents)| {
let full_path = env.repo_root.join(&path);
let status = check_file(full_path, contents)?;
Ok((path, status))
})
.collect::<anyhow::Result<_>>()?;
Ok(extra_files)
}
fn validate_generated_openapi_document(
api: &ManagedApi,
openapi_doc: &OpenAPI,
file_name: &ApiSpecFileName,
is_latest: bool,
is_blessed: Option<bool>,
validation: Option<&DynValidationFn>,
) -> anyhow::Result<ValidationResult> {
let mut validation_context = ValidationContextImpl {
ident: api.ident().clone(),
file_name: file_name.clone(),
versions: api.versions().clone(),
is_latest,
is_blessed,
title: api.title(),
metadata: api.metadata().clone(),
errors: Vec::new(),
files: Vec::new(),
};
if let Some(validation) = validation {
validation(
openapi_doc,
ValidationContext::new(&mut validation_context),
);
}
api.extra_validation(
openapi_doc,
ValidationContext::new(&mut validation_context),
);
if !validation_context.errors.is_empty() {
return Err(anyhow::anyhow!(
"OpenAPI document validation failed:\n{}",
validation_context
.errors
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("\n")
));
}
Ok(ValidationResult { extra_files: validation_context.files })
}
fn check_file(
full_path: Utf8PathBuf,
contents: Vec<u8>,
) -> anyhow::Result<CheckStatus> {
let existing_contents =
read_opt(&full_path).context("failed to read contents on disk")?;
match existing_contents {
Some(existing_contents) if existing_contents == contents => {
Ok(CheckStatus::Fresh)
}
Some(existing_contents) => {
Ok(CheckStatus::Stale(CheckStale::Modified {
full_path,
actual: existing_contents,
expected: contents,
}))
}
None => Ok(CheckStatus::Stale(CheckStale::New { expected: contents })),
}
}
pub fn read_opt(path: &Utf8Path) -> std::io::Result<Option<Vec<u8>>> {
match fs_err::read(path) {
Ok(contents) => Ok(Some(contents)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
#[derive(Debug)]
#[must_use]
pub(crate) enum OverwriteStatus {
Updated,
Unchanged,
}
pub fn overwrite_file(
path: &Utf8Path,
contents: &[u8],
) -> anyhow::Result<OverwriteStatus> {
let existing_contents =
read_opt(path).context("failed to read contents on disk")?;
if existing_contents.as_deref() == Some(contents) {
return Ok(OverwriteStatus::Unchanged);
}
if let Some(parent) = path.parent() {
fs_err::create_dir_all(parent).with_context(|| {
format!("failed to create parent directory for '{}'", path)
})?
}
AtomicFile::new(path, atomicwrites::OverwriteBehavior::AllowOverwrite)
.write(|f| f.write_all(contents))
.with_context(|| format!("failed to write to `{}`", path))?;
Ok(OverwriteStatus::Updated)
}
#[derive(Debug)]
#[must_use]
pub(crate) enum CheckStatus {
Fresh,
Stale(CheckStale),
}
#[derive(Debug)]
#[must_use]
pub(crate) enum CheckStale {
Modified { full_path: Utf8PathBuf, actual: Vec<u8>, expected: Vec<u8> },
New { expected: Vec<u8> },
}
#[derive(Debug)]
#[must_use]
pub struct ValidationResult {
extra_files: Vec<(Utf8PathBuf, Vec<u8>)>,
}
struct ValidationContextImpl {
ident: ApiIdent,
file_name: ApiSpecFileName,
versions: Versions,
is_latest: bool,
is_blessed: Option<bool>,
title: &'static str,
metadata: ManagedApiMetadata,
errors: Vec<anyhow::Error>,
files: Vec<(Utf8PathBuf, Vec<u8>)>,
}
impl ValidationBackend for ValidationContextImpl {
fn ident(&self) -> &ApiIdent {
&self.ident
}
fn file_name(&self) -> &ApiSpecFileName {
&self.file_name
}
fn versions(&self) -> &Versions {
&self.versions
}
fn is_latest(&self) -> bool {
self.is_latest
}
fn is_blessed(&self) -> Option<bool> {
self.is_blessed
}
fn title(&self) -> &str {
self.title
}
fn metadata(&self) -> &ManagedApiMetadata {
&self.metadata
}
fn report_error(&mut self, error: anyhow::Error) {
self.errors.push(error);
}
fn record_file_contents(&mut self, path: Utf8PathBuf, contents: Vec<u8>) {
self.files.push((path, contents));
}
}