blob: 3acba35b2ac70d94edaaced9b645c0c013cafdfe [file] [log] [blame]
// Copyright 2025 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use crate::env::EnvLike;
use crate::errors::BuildError;
use crate::name::Name;
use serde::Deserialize;
use serde_json::Value;
use serde_json5;
use std::collections::HashMap;
use std::fs::File;
use std::io::BufReader;
use std::path::PathBuf;
const DEFAULT_SCHEMA_PATH: &str = "../../build/sdk/test_config_schema.json5";
const SCHEMA_ARG_PREFIX: &str = "--schema=";
/// A JSON schema. `Schema` holds a test config schema in two forms: A serde_json Value suitable
/// for validating JSON against the schema, and an idiomatic form for guiding the processing of test
/// parameters in command lines and environment variables. In this latter form, the schema includes
/// only what's required for that purpose. For example, object types have no properties (because
/// object types are not allowed in command lines or environment variables). Descriptions are also
/// absent as well as enums, which are only used in formal validation.
#[derive(Deserialize, Debug, Default, PartialEq)]
pub struct Schema {
// `Value` form of the schema for validation using `valico::json_schema``.
#[serde(skip_deserializing)]
pub as_value: Value,
// Table of deserialized properties.
pub properties: HashMap<Name, PropertyScheme>,
}
impl Schema {
/// Gets a schema by reading the file specified in the '--schema=' command line argument in
/// `env_like` or by reading from the default file specified by `DEFAULT_SCHEMA_PATH`.
pub fn from_env_like<E: EnvLike>(env_like: &E) -> Result<Self, BuildError> {
if let Some(arg) = env_like.args().find(|a| a.starts_with(SCHEMA_ARG_PREFIX)) {
Self::read(PathBuf::from(arg.strip_prefix(SCHEMA_ARG_PREFIX).unwrap()))
} else {
Self::read(PathBuf::from(DEFAULT_SCHEMA_PATH))
}
}
/// Creates a `Schema` by reading it from the schema from the specified path.
pub fn read(path: PathBuf) -> Result<Self, BuildError> {
let file = File::open(path.as_path())
.map_err(|e| BuildError::FailedToOpenSchema { path: path.clone(), source: e })?;
let mut reader = BufReader::new(file);
Self::from_value(
serde_json5::from_reader(&mut reader)
.map_err(|e| BuildError::FailedToReadSchema { path: path.clone(), source: e })?,
path,
)
}
/// Creates a `Schema' from a `Value``.
pub fn from_value(schema_value: Value, path: PathBuf) -> Result<Self, BuildError> {
let mut schema: Schema = serde_json::from_value(schema_value.clone())
.map_err(|e| BuildError::FailedToParseSchema { path: path.clone(), source: e })?;
schema.as_value = schema_value;
for (name, scheme) in &schema.properties {
scheme.validate(name)?;
}
Ok(schema)
}
}
/// Scheme for properties and array members.
#[derive(Deserialize, Debug, Default, PartialEq)]
pub struct PropertyScheme {
// The type of the property.
#[serde(rename = "type")]
pub property_type: PropertyType,
// The property scheme for array types. For other types, this field is None.
pub items: Option<Box<PropertyScheme>>,
}
impl PropertyScheme {
fn validate(&self, name: &Name) -> Result<(), BuildError> {
match &self.items {
Some(boxed_scheme) => {
if self.property_type != PropertyType::Array {
return Err(BuildError::InvalidSchema(format!(
"Property {} has `items` but is not an array",
name
)));
}
(*boxed_scheme).validate(name)?;
}
None => {
if self.property_type == PropertyType::Array {
return Err(BuildError::InvalidSchema(format!(
"Property {} is an array but has no `items`",
name
)));
}
}
}
Ok(())
}
}
/// Type of a property.
#[derive(Deserialize, Debug, Default, PartialEq)]
pub enum PropertyType {
#[serde(rename = "string")]
#[default]
String,
#[serde(rename = "number")]
Number,
#[serde(rename = "boolean")]
Boolean,
#[serde(rename = "array")]
Array,
// Object types are not supported, but object-typed parameters may still appear in the schema
// without property information.
#[serde(rename = "object")]
Object,
}
#[cfg(test)]
pub mod tests {
use super::*;
use assert_matches::assert_matches;
use serde_json::json;
use std::fs;
use tempfile::NamedTempFile;
pub fn fake_schema() -> Schema {
Schema::from_value(
json!({
"properties": {
"true": { "type": "boolean" },
"false": { "type": "boolean" },
"true_simple": { "type": "boolean" },
"false_simple": { "type": "boolean" },
"no_negative_bool": { "type": "boolean" },
"zero": { "type": "number" },
"zero_point_one": { "type": "number" },
"negative_zero_point_one": { "type": "number" },
"string": { "type": "string" },
"array_of_number": {
"type": "array",
"items": {
"type": "number"
}
},
"array_of_string": {
"type": "array",
"items": {
"type": "string"
}
},
"object": {
"type": "object",
"properties": {
"foo": { "type": "number" },
"bar": { "type": "number" }
}
},
"foo": { "type": "string" },
"bar": { "type": "string" },
"baz": { "type": "string" },
"a": { "type": "string" },
"b": { "type": "string" },
"c": { "type": "string" },
"d": { "type": "string" },
"e": { "type": "string" },
"output_directory": { "type": "string" },
"host_test_binary": { "type": "string" },
"host_test_args": {
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false
}),
PathBuf::new(),
)
.expect("fake schema is valid")
}
#[test]
fn test_read_schema() {
let temp_file = NamedTempFile::new().expect("to create temporary file");
let temp_file_path = temp_file.path().to_str().unwrap().to_string();
fs::write(
temp_file_path,
r#"{
type: "object",
properties: {
output_directory: { type: "string" },
host_test_binary: { type: "string" },
host_test_args: {
type: "array",
items: { type: "string", },
},
},
required: [
"output_directory",
"host_test_binary",
],
additionalProperties: false,
}"#,
)
.expect("to write to temporary file");
assert!(Schema::read(temp_file.path().to_path_buf()).is_ok());
temp_file.close().expect("to close temporary file");
let path_buf = PathBuf::from("/non_existent_file");
let result = Schema::read(path_buf.clone());
assert!(result.is_err());
assert_matches!(
result.unwrap_err(),
BuildError::FailedToOpenSchema { path: p, source: _ }
if p == path_buf
);
let temp_file = NamedTempFile::new().expect("to create temporary file");
let temp_file_path = temp_file.path().to_str().unwrap().to_string();
fs::write(temp_file_path, r#"not a valid schema"#).expect("to write to temporary file");
let result = Schema::read(temp_file.path().to_path_buf());
assert!(result.is_err());
assert_matches!(
result.unwrap_err(),
BuildError::FailedToReadSchema { path: p, source: _ }
if p == temp_file.path().to_path_buf()
);
temp_file.close().expect("to close temporary file");
}
}