use crate::{apis::ManagedApis, environment::ErrorAccumulator};
use anyhow::anyhow;
use camino::{Utf8Path, Utf8PathBuf};
use debug_ignore::DebugIgnore;
use dropshot_api_manager_types::{
ApiIdent, ApiSpecFileName, LockstepApiSpecFileName,
VersionedApiSpecFileName, VersionedApiSpecKind,
};
use git_stub::GitCommitHash;
use openapiv3::OpenAPI;
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, btree_map::Entry},
fmt::Debug,
};
use thiserror::Error;
#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq)]
pub(crate) struct GitStubKey {
pub(crate) ident: ApiIdent,
pub(crate) version: semver::Version,
}
#[derive(Clone, Debug)]
pub struct UnparseableFile {
pub path: Utf8PathBuf,
}
pub(crate) fn parse_versioned_file_name(
apis: &ManagedApis,
ident: &str,
basename: &str,
) -> Result<VersionedApiSpecFileName, BadVersionedFileName> {
let ident = ApiIdent::from(ident.to_string());
let Some(api) = apis.api(&ident) else {
return Err(BadVersionedFileName::NoSuchApi);
};
if !api.is_versioned() {
return Err(BadVersionedFileName::NotVersioned);
}
let expected_prefix = format!("{}-", &ident);
let suffix = basename.strip_prefix(&expected_prefix).ok_or_else(|| {
BadVersionedFileName::UnexpectedName {
ident: ident.clone(),
source: anyhow!("unexpected prefix"),
}
})?;
let middle = suffix.strip_suffix(".json").ok_or_else(|| {
BadVersionedFileName::UnexpectedName {
ident: ident.clone(),
source: anyhow!("bad suffix"),
}
})?;
let (version_str, hash) = middle.rsplit_once("-").ok_or_else(|| {
BadVersionedFileName::UnexpectedName {
ident: ident.clone(),
source: anyhow!("cannot extract version and hash"),
}
})?;
let version: semver::Version =
version_str.parse().map_err(|e: semver::Error| {
BadVersionedFileName::UnexpectedName {
ident: ident.clone(),
source: anyhow!(e).context(format!(
"version string is not a semver: {:?}",
version_str
)),
}
})?;
if !version.pre.is_empty() {
return Err(BadVersionedFileName::UnexpectedName {
ident,
source: anyhow!(
"version string has a prerelease field \
(not supported): {:?}",
version_str
),
});
}
if !version.build.is_empty() {
return Err(BadVersionedFileName::UnexpectedName {
ident,
source: anyhow!(
"version string has a build field (not supported): {:?}",
version_str
),
});
}
Ok(VersionedApiSpecFileName::new(ident, version, hash.to_string()))
}
pub(crate) fn parse_versioned_git_stub_file_name(
apis: &ManagedApis,
ident: &str,
basename: &str,
) -> Result<VersionedApiSpecFileName, BadVersionedFileName> {
let json_basename = basename.strip_suffix(".gitstub").ok_or_else(|| {
BadVersionedFileName::UnexpectedName {
ident: ApiIdent::from(ident.to_string()),
source: anyhow!("expected .json.gitstub suffix"),
}
})?;
let versioned = parse_versioned_file_name(apis, ident, json_basename)?;
Ok(versioned.to_git_stub())
}
pub(crate) fn parse_lockstep_file_name(
apis: &ManagedApis,
basename: &str,
) -> Result<LockstepApiSpecFileName, BadLockstepFileName> {
let ident = ApiIdent::from(
basename
.strip_suffix(".json")
.ok_or(BadLockstepFileName::MissingJsonSuffix)?
.to_owned(),
);
let api = apis.api(&ident).ok_or_else(|| {
BadLockstepFileName::NoSuchApi { ident: ident.clone() }
})?;
if !api.is_lockstep() {
return Err(BadLockstepFileName::NotLockstep);
}
Ok(LockstepApiSpecFileName::new(ident))
}
#[derive(Debug, Error)]
pub(crate) enum BadLockstepFileName {
#[error("expected lockstep API file name to end in \".json\"")]
MissingJsonSuffix,
#[error("does not match a known API")]
NoSuchApi {
ident: ApiIdent,
},
#[error("this API is not a lockstep API")]
NotLockstep,
}
#[derive(Debug, Error)]
pub(crate) enum BadVersionedFileName {
#[error("does not match a known API")]
NoSuchApi,
#[error("this API is not a versioned API")]
NotVersioned,
#[error(
"expected a versioned API document filename for API {ident:?} to look \
like \"{ident:?}-SEMVER-HASH.json\""
)]
UnexpectedName { ident: ApiIdent, source: anyhow::Error },
}
#[derive(Debug, Error)]
enum ApiSpecFileParseError {
#[error("file {path:?}: parsing as JSON")]
JsonParse { path: Utf8PathBuf, source: serde_json::Error },
#[error("file {path:?}: parsing OpenAPI document")]
OpenApiParse { path: Utf8PathBuf, source: serde_json::Error },
#[error("file {path:?}: parsing version from generated spec")]
VersionParse { path: Utf8PathBuf, source: semver::Error },
#[error(
"file {path:?}: version in the file ({file_version}) differs from \
the one in the filename"
)]
VersionMismatch { path: Utf8PathBuf, file_version: semver::Version },
#[error(
"file {path:?}: computed hash {expected:?}, but file name has \
different hash {actual:?}"
)]
HashMismatch { path: Utf8PathBuf, expected: String, actual: String },
}
#[derive(Debug)]
pub struct ApiSpecFile {
name: ApiSpecFileName,
value: DebugIgnore<serde_json::Value>,
contents: DebugIgnore<OpenAPI>,
contents_buf: DebugIgnore<Vec<u8>>,
version: semver::Version,
}
impl ApiSpecFile {
pub fn for_contents(
spec_file_name: ApiSpecFileName,
contents_buf: Vec<u8>,
) -> Result<ApiSpecFile, (anyhow::Error, Vec<u8>)> {
Self::for_contents_inner(spec_file_name, contents_buf)
.map_err(|(e, buf)| (e.into(), buf))
}
fn for_contents_inner(
spec_file_name: ApiSpecFileName,
contents_buf: Vec<u8>,
) -> Result<ApiSpecFile, (ApiSpecFileParseError, Vec<u8>)> {
let value: serde_json::Value =
match serde_json::from_slice(&contents_buf) {
Ok(v) => v,
Err(e) => {
return Err((
ApiSpecFileParseError::JsonParse {
path: spec_file_name.path(),
source: e,
},
contents_buf,
));
}
};
let openapi: OpenAPI = match serde_json::from_slice(&contents_buf) {
Ok(o) => o,
Err(e) => {
return Err((
ApiSpecFileParseError::OpenApiParse {
path: spec_file_name.path(),
source: e,
},
contents_buf,
));
}
};
let parsed_version: semver::Version = match openapi.info.version.parse()
{
Ok(v) => v,
Err(e) => {
return Err((
ApiSpecFileParseError::VersionParse {
path: spec_file_name.path(),
source: e,
},
contents_buf,
));
}
};
match &spec_file_name {
ApiSpecFileName::Versioned(v) => {
if *v.version() != parsed_version {
return Err((
ApiSpecFileParseError::VersionMismatch {
path: spec_file_name.path(),
file_version: parsed_version,
},
contents_buf,
));
}
if v.kind() == VersionedApiSpecKind::Json {
let expected_hash = hash_contents(&contents_buf);
if expected_hash != v.hash() {
return Err((
ApiSpecFileParseError::HashMismatch {
path: spec_file_name.path(),
expected: expected_hash,
actual: v.hash().to_owned(),
},
contents_buf,
));
}
}
}
ApiSpecFileName::Lockstep(_) => {}
}
Ok(ApiSpecFile {
name: spec_file_name,
value: DebugIgnore(value),
contents: DebugIgnore(openapi),
contents_buf: DebugIgnore(contents_buf),
version: parsed_version,
})
}
pub fn spec_file_name(&self) -> &ApiSpecFileName {
&self.name
}
pub fn version(&self) -> &semver::Version {
&self.version
}
pub fn value(&self) -> &serde_json::Value {
&self.value
}
pub fn openapi(&self) -> &OpenAPI {
&self.contents
}
pub fn contents(&self) -> &[u8] {
&self.contents_buf
}
}
pub struct ApiSpecFilesBuilder<'a, T> {
apis: &'a ManagedApis,
spec_files: BTreeMap<ApiIdent, ApiFiles<T>>,
error_accumulator: &'a mut ErrorAccumulator,
}
impl<'a, T: ApiLoad + AsRawFiles> ApiSpecFilesBuilder<'a, T> {
pub fn new(
apis: &'a ManagedApis,
error_accumulator: &'a mut ErrorAccumulator,
) -> ApiSpecFilesBuilder<'a, T> {
ApiSpecFilesBuilder {
apis,
spec_files: BTreeMap::new(),
error_accumulator,
}
}
pub fn load_error(&mut self, error: anyhow::Error) {
self.error_accumulator.error(error);
}
pub fn load_warning(&mut self, error: anyhow::Error) {
self.error_accumulator.warning(error);
}
pub fn lockstep_file_name(
&mut self,
basename: &str,
) -> Option<ApiSpecFileName> {
match parse_lockstep_file_name(self.apis, basename) {
Err(BadLockstepFileName::NoSuchApi { ident })
if T::MISCONFIGURATIONS_ALLOWED =>
{
if !self.apis.unknown_apis().contains(&ident) {
let warning = anyhow!(
"skipping file {basename:?}: {} \
(this is expected if you are deleting an API)",
BadLockstepFileName::NoSuchApi { ident },
);
self.load_warning(warning);
}
None
}
Err(warning @ BadLockstepFileName::NotLockstep)
if T::MISCONFIGURATIONS_ALLOWED =>
{
let warning = anyhow!(
"skipping file {basename:?}: {warning} \
(this is expected if you are converting \
a lockstep API to a versioned one)"
);
self.load_warning(warning);
None
}
Err(warning @ BadLockstepFileName::MissingJsonSuffix) => {
let warning = anyhow!(warning)
.context(format!("skipping file {:?}", basename));
self.load_warning(warning);
None
}
Err(BadLockstepFileName::NoSuchApi { ident })
if self.apis.unknown_apis().contains(&ident) =>
{
let warning = anyhow!(BadLockstepFileName::NoSuchApi { ident })
.context(format!("skipping file {:?}", basename));
self.load_warning(warning);
None
}
Err(error) => {
self.load_error(
anyhow!(error).context(format!("file {:?}", basename)),
);
None
}
Ok(file_name) => Some(file_name.into()),
}
}
pub fn versioned_directory(&mut self, basename: &str) -> Option<ApiIdent> {
let ident = ApiIdent::from(basename.to_owned());
match self.apis.api(&ident) {
Some(api) if api.is_versioned() => Some(ident),
Some(_) => {
let error = anyhow!(
"skipping directory for lockstep API: {:?}",
basename,
);
if T::MISCONFIGURATIONS_ALLOWED {
self.load_warning(error);
} else {
self.load_error(error);
}
None
}
None => {
let error = anyhow!(
"skipping directory for unknown API: {:?}",
basename,
);
if T::MISCONFIGURATIONS_ALLOWED {
self.load_warning(error);
} else {
self.load_error(error);
}
None
}
}
}
pub fn versioned_file_name(
&mut self,
ident: &ApiIdent,
basename: &str,
) -> Option<ApiSpecFileName> {
self.handle_versioned_parse(
parse_versioned_file_name(self.apis, ident, basename),
&format!("file {basename}"),
&format!("skipping file {basename}"),
)
.map(Into::into)
}
pub fn versioned_git_stub_file_name(
&mut self,
ident: &ApiIdent,
basename: &str,
) -> Option<ApiSpecFileName> {
self.handle_versioned_parse(
parse_versioned_git_stub_file_name(self.apis, ident, basename),
&format!("Git stub {basename}"),
&format!("skipping Git stub {basename}"),
)
.map(Into::into)
}
pub fn symlink_contents(
&mut self,
symlink_path: &Utf8Path,
ident: &ApiIdent,
basename: &str,
) -> Option<VersionedApiSpecFileName> {
self.handle_versioned_parse(
parse_versioned_file_name(self.apis, ident, basename),
&format!("bad symlink {symlink_path} pointing to {basename}"),
&format!("ignoring symlink {symlink_path} pointing to {basename}"),
)
}
fn handle_versioned_parse(
&mut self,
result: Result<VersionedApiSpecFileName, BadVersionedFileName>,
error_label: &str,
warning_label: &str,
) -> Option<VersionedApiSpecFileName> {
match result {
Ok(file_name) => Some(file_name),
Err(
warning @ (BadVersionedFileName::NoSuchApi
| BadVersionedFileName::NotVersioned),
) if T::MISCONFIGURATIONS_ALLOWED => {
self.load_warning(
anyhow!(warning).context(warning_label.to_owned()),
);
None
}
Err(warning @ BadVersionedFileName::UnexpectedName { .. }) => {
self.load_warning(
anyhow!(warning).context(warning_label.to_owned()),
);
None
}
Err(error) => {
self.load_error(anyhow!(error).context(error_label.to_owned()));
None
}
}
}
pub fn load_parsed(&mut self, file: ApiSpecFile) {
let ident = file.spec_file_name().ident();
let api_version = file.version();
let entry = self
.spec_files
.entry(ident.clone())
.or_insert_with(ApiFiles::new)
.spec_files
.entry(api_version.clone());
match entry {
Entry::Vacant(vacant_entry) => {
vacant_entry.insert(T::make_item(file));
}
Entry::Occupied(mut occupied_entry) => {
match occupied_entry.get_mut().try_extend(file) {
Ok(()) => (),
Err(error) => self.load_error(error),
};
}
};
}
pub fn load_maybe_unparseable(
&mut self,
file_name: ApiSpecFileName,
result: Result<ApiSpecFile, (anyhow::Error, Vec<u8>)>,
) {
match result {
Ok(file) => {
self.load_parsed(file);
}
Err((error, contents)) => {
self.insert_unparseable(file_name, contents, error);
}
}
}
pub fn load_unparseable(
&mut self,
file_name: ApiSpecFileName,
contents: Vec<u8>,
reason: anyhow::Error,
) {
self.insert_unparseable(file_name, contents, reason);
}
fn insert_unparseable(
&mut self,
file_name: ApiSpecFileName,
contents: Vec<u8>,
reason: anyhow::Error,
) {
match T::make_unparseable(file_name.clone(), contents) {
Some(unparseable) => {
self.load_warning(reason.context("skipping unparseable file"));
if let Some(version) = file_name.version() {
let ident = file_name.ident().clone();
let entry = self
.spec_files
.entry(ident)
.or_insert_with(ApiFiles::new)
.spec_files
.entry(version.clone());
match entry {
Entry::Vacant(vacant_entry) => {
vacant_entry
.insert(T::unparseable_into_self(unparseable));
}
Entry::Occupied(mut occupied_entry) => {
occupied_entry
.get_mut()
.extend_unparseable(unparseable);
}
}
} else {
self.record_unparseable_file(
file_name.ident().clone(),
UnparseableFile { path: file_name.path() },
);
}
}
None => {
self.load_error(reason);
}
}
}
fn record_unparseable_file(
&mut self,
ident: ApiIdent,
unparseable: UnparseableFile,
) {
self.spec_files
.entry(ident)
.or_insert_with(ApiFiles::new)
.unparseable_files
.push(unparseable);
}
pub fn load_latest_link(
&mut self,
ident: &ApiIdent,
links_to: VersionedApiSpecFileName,
) {
let Some(api) = self.apis.api(ident) else {
let error =
anyhow!("link for unknown API {:?} ({})", ident, links_to);
if T::MISCONFIGURATIONS_ALLOWED {
self.load_warning(error);
} else {
self.load_error(error);
}
return;
};
if !api.is_versioned() {
let error = anyhow!(
"link for non-versioned API {:?} ({})",
ident,
links_to
);
if T::MISCONFIGURATIONS_ALLOWED {
self.load_warning(error);
} else {
self.load_error(error);
}
return;
}
let api_files =
self.spec_files.entry(ident.clone()).or_insert_with(ApiFiles::new);
if let Some(previous) = api_files.latest_link.replace(links_to) {
let new_link = api_files.latest_link.as_ref().unwrap().to_string();
self.load_error(anyhow!(
"API {:?}: multiple \"latest\" links (at least {}, {})",
ident,
previous,
new_link,
));
}
}
pub fn set_git_stub_commit(
&mut self,
ident: &ApiIdent,
version: &semver::Version,
commit: GitCommitHash,
) {
if let Some(api_files) = self.spec_files.get_mut(ident)
&& let Some(item) = api_files.spec_files.get_mut(version)
{
item.set_git_stub_commit(commit);
}
}
pub(crate) fn lookup_versioned_dir(
&mut self,
seen_dirs: &mut BTreeMap<String, Option<ApiIdent>>,
dir_basename: &str,
) -> Option<ApiIdent> {
seen_dirs
.entry(dir_basename.to_owned())
.or_insert_with(|| self.versioned_directory(dir_basename))
.clone()
}
pub fn into_map(self) -> BTreeMap<ApiIdent, ApiFiles<T>> {
self.spec_files
}
}
#[derive(Debug)]
pub struct ApiFiles<T> {
spec_files: BTreeMap<semver::Version, T>,
latest_link: Option<VersionedApiSpecFileName>,
unparseable_files: Vec<UnparseableFile>,
}
impl<T: AsRawFiles> ApiFiles<T> {
fn new() -> ApiFiles<T> {
ApiFiles {
spec_files: BTreeMap::new(),
latest_link: None,
unparseable_files: Vec::new(),
}
}
pub fn versions(&self) -> &BTreeMap<semver::Version, T> {
&self.spec_files
}
pub fn latest_link(&self) -> Option<&VersionedApiSpecFileName> {
self.latest_link.as_ref()
}
pub fn unparseable_files(&self) -> &[UnparseableFile] {
&self.unparseable_files
}
}
pub trait SpecFileInfo {
fn spec_file_name(&self) -> &ApiSpecFileName;
fn version(&self) -> Option<&semver::Version>;
}
impl SpecFileInfo for ApiSpecFile {
fn spec_file_name(&self) -> &ApiSpecFileName {
&self.name
}
fn version(&self) -> Option<&semver::Version> {
Some(&self.version)
}
}
pub trait AsRawFiles: Debug {
fn as_raw_files<'a>(
&'a self,
) -> Box<dyn Iterator<Item = &'a dyn SpecFileInfo> + 'a>;
}
impl AsRawFiles for Vec<ApiSpecFile> {
fn as_raw_files<'a>(
&'a self,
) -> Box<dyn Iterator<Item = &'a dyn SpecFileInfo> + 'a> {
Box::new(self.iter().map(|f| f as &dyn SpecFileInfo))
}
}
pub trait ApiLoad {
const MISCONFIGURATIONS_ALLOWED: bool;
type Unparseable;
fn make_item(raw: ApiSpecFile) -> Self;
fn try_extend(&mut self, raw: ApiSpecFile) -> anyhow::Result<()>;
fn make_unparseable(
name: ApiSpecFileName,
contents: Vec<u8>,
) -> Option<Self::Unparseable>;
fn unparseable_into_self(unparseable: Self::Unparseable) -> Self
where
Self: Sized;
fn extend_unparseable(&mut self, unparseable: Self::Unparseable);
fn set_git_stub_commit(&mut self, _commit: GitCommitHash) {}
}
pub(crate) fn hash_contents(contents: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(contents);
let computed_hash = hasher.finalize();
hex::encode(&computed_hash.as_slice()[0..3])
}
#[cfg(test)]
mod test {
use super::*;
use crate::ManagedApiConfig;
use anyhow::Context;
use assert_matches::assert_matches;
use dropshot::{ApiDescription, ApiDescriptionBuildErrors, StubContext};
use dropshot_api_manager_types::{
ManagedApiMetadata, SupportedVersion, SupportedVersions, Versions,
};
use semver::Version;
#[test]
fn test_parse_name_lockstep() {
let apis = all_apis().unwrap();
let name = parse_lockstep_file_name(&apis, "lockstep.json").unwrap();
assert_eq!(
name,
LockstepApiSpecFileName::new(ApiIdent::from("lockstep".to_owned()))
);
}
#[test]
fn test_parse_name_versioned() {
let apis = all_apis().unwrap();
let name = parse_versioned_file_name(
&apis,
"versioned",
"versioned-1.2.3-feedface.json",
)
.unwrap();
assert_eq!(
name,
VersionedApiSpecFileName::new(
ApiIdent::from("versioned".to_owned()),
Version::new(1, 2, 3),
"feedface".to_owned(),
)
);
}
#[test]
fn test_parse_name_lockstep_fail() {
let apis = all_apis().unwrap();
let error = parse_lockstep_file_name(&apis, "lockstep").unwrap_err();
assert_matches!(error, BadLockstepFileName::MissingJsonSuffix);
let error =
parse_lockstep_file_name(&apis, "bart-simpson.json").unwrap_err();
assert_matches!(
error,
BadLockstepFileName::NoSuchApi { ident } if ident == "bart-simpson".into()
);
let error =
parse_lockstep_file_name(&apis, "versioned.json").unwrap_err();
assert_matches!(error, BadLockstepFileName::NotLockstep);
}
#[test]
fn test_parse_name_versioned_fail() {
let apis = all_apis().unwrap();
let error = parse_versioned_file_name(
&apis,
"bart-simpson",
"bart-simpson-1.2.3-hash.json",
)
.unwrap_err();
assert_matches!(error, BadVersionedFileName::NoSuchApi);
let error = parse_versioned_file_name(
&apis,
"lockstep",
"lockstep-1.2.3-hash.json",
)
.unwrap_err();
assert_matches!(error, BadVersionedFileName::NotVersioned);
let error =
parse_versioned_file_name(&apis, "versioned", "1.2.3-hash.json")
.unwrap_err();
assert_matches!(error, BadVersionedFileName::UnexpectedName { .. });
let error = parse_versioned_file_name(
&apis,
"versioned",
"versioned-1.2.3.json",
)
.unwrap_err();
assert_matches!(error, BadVersionedFileName::UnexpectedName { .. });
let error = parse_versioned_file_name(
&apis,
"versioned",
"versioned-hash.json",
)
.unwrap_err();
assert_matches!(error, BadVersionedFileName::UnexpectedName { .. });
let error = parse_versioned_file_name(
&apis,
"versioned",
"versioned-1.2.3-hash",
)
.unwrap_err();
assert_matches!(error, BadVersionedFileName::UnexpectedName { .. });
let error = parse_versioned_file_name(
&apis,
"versioned",
"versioned-bogus-hash",
)
.unwrap_err();
assert_matches!(error, BadVersionedFileName::UnexpectedName { .. });
}
#[test]
fn test_parse_name_versioned_git_stub_valid() {
let apis = all_apis().unwrap();
let name = parse_versioned_git_stub_file_name(
&apis,
"versioned",
"versioned-1.2.3-feedface.json.gitstub",
)
.unwrap();
assert_eq!(
name,
VersionedApiSpecFileName::new_git_stub(
ApiIdent::from("versioned".to_owned()),
Version::new(1, 2, 3),
"feedface".to_owned(),
)
);
}
#[test]
fn test_parse_name_versioned_git_stub_invalid() {
let apis = all_apis().unwrap();
let error = parse_versioned_git_stub_file_name(
&apis,
"versioned",
"versioned-1.2.3-feedface.json",
)
.unwrap_err();
assert_matches!(error, BadVersionedFileName::UnexpectedName { .. });
let error = parse_versioned_git_stub_file_name(
&apis,
"unknown",
"unknown-1.2.3-feedface.json.gitstub",
)
.unwrap_err();
assert_matches!(error, BadVersionedFileName::NoSuchApi);
let error = parse_versioned_git_stub_file_name(
&apis,
"lockstep",
"lockstep-1.2.3-feedface.json.gitstub",
)
.unwrap_err();
assert_matches!(error, BadVersionedFileName::NotVersioned);
let error = parse_versioned_git_stub_file_name(
&apis,
"versioned",
"versioned-badversion-feedface.json.gitstub",
)
.unwrap_err();
assert_matches!(error, BadVersionedFileName::UnexpectedName { .. });
}
fn all_apis() -> anyhow::Result<ManagedApis> {
let apis = vec![
ManagedApiConfig {
ident: "lockstep",
versions: Versions::Lockstep {
version: "1.0.0".parse().unwrap(),
},
title: "Lockstep API",
metadata: ManagedApiMetadata {
description: Some("A simple lockstep-versioned API"),
..ManagedApiMetadata::default()
},
api_description: unimplemented_fn,
},
ManagedApiConfig {
ident: "versioned",
versions: Versions::Versioned {
supported_versions: SupportedVersions::new(vec![
SupportedVersion::new(Version::new(1, 0, 0), "initial"),
]),
},
title: "Versioned API",
metadata: ManagedApiMetadata {
description: Some("A simple lockstep-versioned API"),
..ManagedApiMetadata::default()
},
api_description: unimplemented_fn,
},
];
let apis =
ManagedApis::new(apis).context("error creating ManagedApis")?;
Ok(apis)
}
fn unimplemented_fn()
-> Result<ApiDescription<StubContext>, ApiDescriptionBuildErrors> {
unimplemented!("this shouldn't be called, not part of test")
}
}