Skip to content

Commit

Permalink
feat: Add support for validating output schemas using jsonschema
Browse files Browse the repository at this point in the history
Signed-off-by: Yoriyasu Yano <[email protected]>
  • Loading branch information
yorinasub17 committed Dec 6, 2023
1 parent 6789e47 commit 26b8d75
Show file tree
Hide file tree
Showing 11 changed files with 734 additions and 10 deletions.
569 changes: 566 additions & 3 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ deno_ast = { version = "0.31.3", features = ["transpiling"] }
deno_core = "0.233.0"
env_logger = "0.10.0"
handlebars = "4.5.0"
jsonschema = { version = "0.17.1", features = [ "draft202012" ] }
lazy_static = "1.4.0"
log = "0.4.20"
path-clean = "1.0.1"
regex = "1.10.2"
serde_json = "1.0.108"
serde_yaml = "0.9.27"
tokio = { version = "1.33.0", features = ["full"] }
uuid = { version = "1.5.0", features = ["v4"] }
Expand Down
1 change: 1 addition & 0 deletions packages/types/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ declare namespace senc {
out_ext?: string;
out_type: "yaml" | "json";
out_prefix?: string;
schema_path?: string;
data: any;
});

Expand Down
2 changes: 1 addition & 1 deletion packages/types/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fensak-io/senc-types",
"version": "0.0.4",
"version": "0.0.5",
"author": "Fensak, LLC <[email protected]> (https://fensak.io)",
"license": "MPL-2.0",
"description": "Core type definitions for senc.",
Expand Down
4 changes: 3 additions & 1 deletion src/builtins/senc.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@
*/
class OutData {
constructor(attrs) {
this.out_type = attrs.out_type || "";

this.out_path = attrs.out_path;
this.out_ext = attrs.out_ext;
this.out_type = attrs.out_type;
this.out_prefix = attrs.out_prefix
this.schema_path = attrs.schema_path
this.data = attrs.data;
}

Expand Down
70 changes: 65 additions & 5 deletions src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ use deno_core::*;
use crate::files;
use crate::module_loader;
use crate::ops;
use crate::validator;
use crate::validator::DataSchema;

// Load and embed the runtime snapshot built from the build script.
static RUNTIME_SNAPSHOT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/SENC_SNAPSHOT.bin"));
Expand Down Expand Up @@ -96,11 +98,14 @@ pub async fn run_js_and_write(ctx: &Context, req: &RunRequest) -> Result<()> {

// Run the javascript or typescript file available at the given file path through the Deno runtime.
async fn run_js(ctx: &Context, req: &RunRequest) -> Result<vec::Vec<OutData>> {
let script_path = path::Path::new(&req.in_file);
let script_dir = script_path.parent().unwrap();

let mut js_runtime = new_runtime(ctx, req)?;
let mod_id = load_main_module(&mut js_runtime, &req.in_file).await?;
let main_fn = load_main_fn(&mut js_runtime, mod_id)?;
let result = call_main_fn(ctx, &mut js_runtime, main_fn).await?;
return load_result(&mut js_runtime, result);
return load_result(&script_dir, &mut js_runtime, result);
}

// Initialize a new JsRuntime object (which represents an Isolate) with all the extensions loaded.
Expand Down Expand Up @@ -189,6 +194,7 @@ async fn call_main_fn(
// Load the result from the main function as a vector of OutData that can be outputed to disk. Each
// OutData represents a single file that should be outputed.
fn load_result(
script_dir: &path::Path,
js_runtime: &mut JsRuntime,
result: v8::Global<v8::Value>,
) -> Result<vec::Vec<OutData>> {
Expand All @@ -205,11 +211,11 @@ fn load_result(
let sz = result_arr_raw.length();
for i in 0..sz {
let item = result_arr_raw.get_index(&mut scope, i).unwrap();
let single_out = load_one_result(&mut scope, item)?;
let single_out = load_one_result(script_dir, &mut scope, item)?;
out.push(single_out);
}
} else {
let single_out = load_one_result(&mut scope, result_local)?;
let single_out = load_one_result(script_dir, &mut scope, result_local)?;
out.push(single_out);
}

Expand All @@ -221,6 +227,7 @@ fn load_result(
// allows customization of the output behavior on a file by file basis.
// - Anything else would be treated as raw object to be serialized to JSON.
fn load_one_result<'a>(
script_dir: &path::Path,
scope: &mut v8::HandleScope<'a>,
orig_result_local: v8::Local<'a, v8::Value>,
) -> Result<OutData> {
Expand All @@ -231,19 +238,23 @@ fn load_one_result<'a>(
let mut out_ext = Some(String::from(".json"));
let mut out_type = OutputType::JSON;
let mut out_prefix: Option<String> = None;
let mut schema_path: Option<String> = None;

// Determine if the raw JS object from the runtime is an out data object, and if it is, process
// it.
if result_is_sencjs_out_data(scope, result_local)? {
let (op, oe, ot, opre, rs) = load_one_sencjs_out_data_result(scope, result_local)?;
let (op, oe, ot, opre, sp, rs) = load_one_sencjs_out_data_result(scope, result_local)?;
out_path = op;
out_ext = oe;
out_type = ot;
out_prefix = opre;
schema_path = sp;
result_local = rs;
}

let deserialized_result = serde_v8::from_v8::<serde_json::Value>(scope, result_local)?;
validate_result(script_dir, schema_path, &deserialized_result)?;

let data = match out_type {
// NOTE
// Both serde_json and serde_yaml have consistent outputs, so we don't need to do anything
Expand Down Expand Up @@ -273,13 +284,16 @@ fn load_one_sencjs_out_data_result<'a>(
OutputType,
// out_prefix
Option<String>,
// schema_path
Option<String>,
// result_local
v8::Local<'a, v8::Value>,
)> {
let mut out_path: Option<String> = None;
let mut out_ext: Option<String> = None;
let mut out_ext: Option<String> = Some(String::from(".json"));
let mut out_type = OutputType::JSON;
let mut out_prefix: Option<String> = None;
let mut schema_path: Option<String> = None;

let result_obj: v8::Local<v8::Object> = result_local.try_into()?;
let out_type_key: v8::Local<v8::Value> = v8::String::new(scope, "out_type").unwrap().into();
Expand All @@ -297,9 +311,12 @@ fn load_one_sencjs_out_data_result<'a>(
let out_path_key: v8::Local<v8::Value> = v8::String::new(scope, "out_path").unwrap().into();
let out_ext_key: v8::Local<v8::Value> = v8::String::new(scope, "out_ext").unwrap().into();
let out_prefix_key: v8::Local<v8::Value> = v8::String::new(scope, "out_prefix").unwrap().into();
let schema_path_key: v8::Local<v8::Value> =
v8::String::new(scope, "schema_path").unwrap().into();
let maybe_out_path: v8::Local<v8::Value> = result_obj.get(scope, out_path_key).unwrap();
let maybe_out_ext: v8::Local<v8::Value> = result_obj.get(scope, out_ext_key).unwrap();
let maybe_out_prefix: v8::Local<v8::Value> = result_obj.get(scope, out_prefix_key).unwrap();
let maybe_schema_path: v8::Local<v8::Value> = result_obj.get(scope, schema_path_key).unwrap();

if maybe_out_path.is_string() && maybe_out_ext.is_string() {
return Err(anyhow!(
Expand All @@ -320,12 +337,18 @@ fn load_one_sencjs_out_data_result<'a>(
out_prefix = Some(out_prefix_local.to_rust_string_lossy(scope));
}

if maybe_schema_path.is_string() {
let schema_path_local: v8::Local<v8::String> = maybe_schema_path.try_into()?;
schema_path = Some(schema_path_local.to_rust_string_lossy(scope));
}

let out_data_key: v8::Local<v8::Value> = v8::String::new(scope, "data").unwrap().into();
Ok((
out_path,
out_ext,
out_type,
out_prefix,
schema_path,
result_obj.get(scope, out_data_key).unwrap().try_into()?,
))
}
Expand Down Expand Up @@ -464,6 +487,26 @@ fn load_templated_builtins(ctx: &Context, req: &RunRequest) -> Result<Extension>
Ok(ext)
}

// Validate the result data against a specified schema. If no schema is specified, this function
// does nothing.
fn validate_result(
script_dir: &path::Path,
maybe_schema_path: Option<String>,
result: &serde_json::Value,
) -> Result<()> {
let schema_path_str = match maybe_schema_path {
None => {
return Ok(());
}
Some(d) => d,
};
let mut schema_path = path::PathBuf::from(script_dir);
schema_path.push(schema_path_str);
let schema_path_abs = fs::canonicalize(schema_path)?;
let schema = validator::new_from_path(schema_path_abs.as_path())?;
return schema.validate(result);
}

// Test cases

#[cfg(test)]
Expand All @@ -479,6 +522,7 @@ mod tests {
static EXPECTED_IMPORT_CONFIG_OUTPUT_JSON: &str = "{\"msg\":\"hello world\"}";
static EXPECTED_ARGS_OUTPUT_JSON: &str =
"{\"arg1\":[\"hello world\"],\"arg2\":{\"msg\":\"hello world\"}}";
static EXPECTED_JSONSCHEMA_OUTPUT_JSON: &str = "{\"productId\":5}";

#[tokio::test]
async fn test_engine_runs_js() {
Expand Down Expand Up @@ -510,6 +554,22 @@ mod tests {
check_single_json_output(EXPECTED_RELPATH_OUTPUT_JSON, "aws/us-east-1/vpc/main.js").await;
}

#[tokio::test]
async fn test_engine_checks_schema_pass() {
check_single_json_output(EXPECTED_JSONSCHEMA_OUTPUT_JSON, "jsonschema/pass.js").await;
}

#[tokio::test]
async fn test_engine_checks_schema_fail() {
let p = get_fixture_path("jsonschema/fail.js");
let req = RunRequest {
in_file: String::from(p.as_path().to_string_lossy()),
out_file_stem: String::from(""),
};
let result = run_js(&get_context(&[]), &req).await;
assert!(result.is_err());
}

#[tokio::test]
async fn test_engine_runs_js_with_args() {
let arg1 = "[\"hello world\"]";
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod logger;
mod module_loader;
mod ops;
mod threadpool;
mod validator;

use std::fs;
use std::path;
Expand Down
60 changes: 60 additions & 0 deletions src/validator.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) Fensak, LLC.
// SPDX-License-Identifier: MPL-2.0

use std::fs;
use std::io;
use std::path;

use anyhow::{anyhow, Result};
use jsonschema::{Draft, JSONSchema};

pub trait DataSchema {
fn validate(&self, data: &serde_json::Value) -> Result<()>;
}

pub struct DataJSONSchema {
schema: JSONSchema,
}

impl DataSchema for DataJSONSchema {
fn validate(&self, data: &serde_json::Value) -> Result<()> {
match self.schema.validate(data) {
Err(errs) => {
let mut err_strs = Vec::new();
for err in errs {
let instance_path_str = err.instance_path.to_string();
let err_str = if instance_path_str == "" {
format!("[.] {}", err).to_string()
} else {
format!("[{}] {}\n", instance_path_str, err).to_string()
};
err_strs.push(err_str);
}
Err(anyhow!(err_strs.join("\n")))
}
Ok(result) => Ok(result),
}
}
}

pub fn new_from_path(schema_path: &path::Path) -> Result<impl DataSchema> {
let schema_file = fs::File::open(schema_path)?;
let schema_reader = io::BufReader::new(schema_file);
let raw_schema: serde_json::Value = serde_json::from_reader(schema_reader)?;

let maybe_jsonschema: Result<JSONSchema, _> = JSONSchema::options()
.with_draft(Draft::Draft202012)
.compile(&raw_schema);
match maybe_jsonschema {
Ok(jsonschema) => {
return Ok(DataJSONSchema { schema: jsonschema });
}
Err(err) => {
return Err(anyhow!(
"Could not load schema {}: {}",
schema_path.to_string_lossy(),
err
));
}
};
}
12 changes: 12 additions & 0 deletions tests/fixtures/jsonschema/fail.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (c) Fensak, LLC.
// SPDX-License-Identifier: MPL-2.0

export function main() {
return new senc.OutData({
schema_path: "schema.json",
data: {
productId: 5,
shouldNotHave: true,
},
});
}
9 changes: 9 additions & 0 deletions tests/fixtures/jsonschema/pass.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright (c) Fensak, LLC.
// SPDX-License-Identifier: MPL-2.0

export function main() {
return new senc.OutData({
schema_path: "schema.json",
data: { productId: 5 },
});
}
14 changes: 14 additions & 0 deletions tests/fixtures/jsonschema/schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/product.schema.json",
"title": "Product",
"description": "A product in the catalog",
"type": "object",
"properties": {
"productId": {
"description": "The unique identifier for a product",
"type": "integer"
}
},
"additionalProperties": false
}

0 comments on commit 26b8d75

Please sign in to comment.